Why Quick Edit Bypasses Your save_post Logic: wp_ajax_inline_save, the $_POST Field Whitelist, and the Columns That Update While postmeta Silently Doesn’t

Symptom: An editor changes a post’s title, slug, or status in the Posts list table’s Quick Edit row. The list refreshes. The title changes. The slug changes. The status changes. And the custom field your theme reads on the front end — the one you wrote a tidy save_post callback to keep in sync — does not change. No error. No notice. The AJAX response is a fresh table row, not a JSON error, so nothing looks wrong.

This is not a bug in your callback. It is the expected behavior of a save path that never had your field in scope to begin with. Quick Edit is a narrow, whitelisted update. If you assume it behaves like the classic editor’s post.php submit, you will keep writing callbacks that fire and then do nothing useful.

Trace: what actually handles the Quick Edit request

Quick Edit posts to admin-ajax.php with action=inline-save. WordPress routes that to wp_ajax_inline_save(), defined in wp-admin/includes/ajax-actions.php. The function is short enough to read in one sitting, and reading it is the whole exercise.

The first thing it does is verify the nonce inlineeditnonce against $_POST['_inline_edit'], then check post_ID, then check the capability for the specific post. If a post lock exists, it prints a message and dies. Assuming those pass, it does this:

$data = &$_POST;
$post = get_post( $post_id, ARRAY_A );
$post = wp_slash( $post );
$data['content'] = $post['post_content'];
$data['excerpt'] = $post['post_excerpt'];
$data['user_ID'] = get_current_user_id();

Two lines matter more than the rest. Quick Edit does not send content or excerpt in its payload, so the handler backfills them from the database row it just fetched. That is deliberate: Quick Edit is not supposed to touch the body. It then renames post_parent to parent_id, resolves status from keep_private or _status, defaults comment_status and ping_status to closed when empty, and strips any taxonomy from tax_input whose show_in_quick_edit is false (filterable via quick_edit_show_taxonomy).

Then it calls edit_post() with no arguments. Per the function reference, edit_post() with a null $post_data uses the $_POST superglobal. So the entire Quick Edit save is $_POST, filtered through _wp_translate_postdata() and then _wp_get_allowed_postdata().

Root cause: the whitelist is the contract

_wp_get_allowed_postdata() returns only the fields WordPress considers post columns. Anything else in $_POST is discarded before wp_update_post() runs. This is the field whitelist the topic names, and it is why a custom field posted by a hand-rolled form will not survive the trip. Quick Edit’s own JS never sends your meta key anyway, so the whitelist is the second gate, not the first.

Inside edit_post(), meta is handled in exactly two places:

if ( isset( $post_data['meta'] ) && $post_data['meta'] ) {
    foreach ( $post_data['meta'] as $key => $value ) {
        $meta = get_post_meta_by_id( $key );
        if ( ! $meta ) { continue; }
        if ( (int) $meta->post_id !== $post_id ) { continue; }
        if ( is_protected_meta( $meta->meta_key, 'post' ) || ! current_user_can( 'edit_post_meta', $post_id, $meta->meta_key ) ) { continue; }
        if ( is_protected_meta( $value['key'], 'post' ) || ! current_user_can( 'edit_post_meta', $post_id, $value['key'] ) ) { continue; }
        update_meta( $key, $value['key'], $value['value'] );
    }
}
if ( isset( $post_data['deletemeta'] ) && $post_data['deletemeta'] ) {
    foreach ( $post_data['deletemeta'] as $key => $value ) {
        $meta = get_post_meta_by_id( $key );
        if ( ! $meta ) { continue; }
        if ( (int) $meta->post_id !== $post_id ) { continue; }
        if ( is_protected_meta( $meta->meta_key, 'post' ) || ! current_user_can( 'delete_post_meta', $post_id, $meta->meta_key ) ) { continue; }
        delete_meta( $key );
    }
}

Note the shape. meta is keyed by meta ID, not by meta key. Each entry carries a key and a value. It updates existing rows only — get_post_meta_by_id() returns false for a key that does not exist, and the loop continues. There is no insert path here. Quick Edit’s JS does not populate meta or deletemeta at all, so both branches are dead on arrival for a Quick Edit request.

Now the part that trips people: edit_post() ends with wp_update_post( $translated ), and wp_update_post() fires save_post. So save_post does run on Quick Edit. The hook is not bypassed. What is bypassed is the data your callback expects to find in $_POST.

This is the trap. A callback written like this:

add_action( 'save_post', function ( $post_id ) {
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;
    if ( ! isset( $_POST['my_meta_nonce'] ) ) return;
    if ( ! wp_verify_nonce( $_POST['my_meta_nonce'], 'my_meta' ) ) return;
    if ( ! current_user_can( 'edit_post', $post_id ) ) return;
    update_post_meta( $post_id, 'my_meta', sanitize_text_field( $_POST['my_meta'] ) );
} );

returns early on every Quick Edit save, because my_meta_nonce is not in the payload. The post row updates. The meta does not. The editor sees a successful save. You see a stale value on the front end two hours later.

The save_post documentation is explicit that the data lives in $_POST, $_GET, or the global $post_data depending on the edit path, and that quick edits use $_POST. It does not promise that $_POST contains your fields. That is the gap.

What Quick Edit actually updates

From the handler and the JS that drives it, the columns in play are the ones the list table renders inline: post_title, post_name (slug), post_status via _status or keep_private, post_author, post_parent (renamed to parent_id), comment_status, ping_status, post_password via visibility, sticky, menu_order, post_date, and tax_input for taxonomies that opt in. Content and excerpt are preserved from the database, not edited.

Everything else — every custom field, every term in a taxonomy with show_in_quick_edit false, every piece of postmeta your theme or plugin owns — is untouched. Not deleted. Not zeroed. Untouched. The row keeps whatever value it had before the Quick Edit, which is why the failure is silent: the old value is still there, still valid, just no longer correct.

Fix: stop reading $_POST, start reading the post

The durable pattern is to make your save_post callback idempotent and derive its inputs from the post object and the request context, not from a nonce that only exists on one save path. Two approaches work.

Approach one: recompute from post columns. If your meta is a function of the post’s own fields — a reading-time estimate from post_content, a slug-derived key, a status flag — compute it on every save_post and write it. Quick Edit changes the title, your callback recomputes, the meta stays consistent. No nonce needed, because you are not trusting user input; you are deriving from data WordPress already validated.

add_action( 'save_post_post', function ( $post_id, $post, $update ) {
    if ( wp_is_post_revision( $post_id ) ) return;
    if ( 'publish' !== $post->post_status ) return;
    $words = str_word_count( wp_strip_all_tags( $post->post_content ) );
    update_post_meta( $post_id, '_reading_minutes', max( 1, (int) ceil( $words / 220 ) ) );
}, 10, 3 );

This runs on Quick Edit, classic editor, block editor, WP-CLI, and REST, because all of them end in wp_update_post() and all of them fire save_post. The save_post_{post_type} variant is preferred when you only care about one post type; the docs note it fires before the generic save_post, and that plugins hooking the generic action will still run.

Approach two: gate on the fields you actually need, not on a nonce. If your meta comes from a form field that only exists in the classic editor, check for that field specifically and bail if it is absent — but do not treat absence as failure. Absence means “this save path does not own this field,” which is correct for Quick Edit.

add_action( 'save_post_post', function ( $post_id, $post, $update ) {
    if ( wp_is_post_revision( $post_id ) ) return;
    if ( ! isset( $_POST['my_meta'] ) ) return; // Quick Edit: leave existing value alone.
    if ( ! isset( $_POST['my_meta_nonce'] ) || ! wp_verify_nonce( $_POST['my_meta_nonce'], 'my_meta' ) ) return;
    if ( ! current_user_can( 'edit_post', $post_id ) ) return;
    update_post_meta( $post_id, 'my_meta', sanitize_text_field( wp_unslash( $_POST['my_meta'] ) ) );
}, 10, 3 );

The difference from the broken version is the order: check for the field first, nonce second. The broken version checks the nonce first and bails, which is indistinguishable from “field not present” but reads as “security failure” in your head. Reordering makes the intent legible and the behavior correct.

If you need Quick Edit to actually edit your meta, you have to add it to the Quick Edit form yourself. That means hooking quick_edit_custom_box to render the input, enqueueing JS that copies the row’s current value into the form on open, and extending the AJAX payload. The handler will pass unknown fields through $_POST to edit_post(), but _wp_get_allowed_postdata() will drop them before wp_update_post(), so you must read them in your own save_post callback and write the meta yourself. This is the documented extension point; it is not a supported way to smuggle arbitrary fields into the post row.

Verification

Do not trust the list table refresh. Verify at the database and at the hook.

Check the meta value directly. Before the Quick Edit, note the value:

wp post meta get 1234 my_meta

Run the Quick Edit, changing only the title. Then:

wp post meta get 1234 my_meta
wp post get 1234 --field=post_title

If the title changed and the meta did not, and your callback is supposed to derive the meta from the title, the callback is not running or is bailing. If the meta did change, the callback ran and did its job.

Confirm save_post fires. Drop a temporary logger into wp-content/mu-plugins/:

add_action( 'save_post', function ( $post_id, $post, $update ) {
    error_log( sprintf( 'save_post %d %s update=%s', $post_id, $post->post_type, $update ? 'yes' : 'no' ) );
}, 10, 3 );

Trigger a Quick Edit and watch wp-content/debug.log with WP_DEBUG_LOG enabled. You will see the line. That confirms the hook is not the problem; the payload is.

Inspect the actual request. Open DevTools, Network tab, filter for admin-ajax.php, and trigger a Quick Edit. Click the request, then the Payload or Form Data tab. You will see action=inline-save, _inline_edit, post_ID, post_type, post_view, screen, and the fields the row actually exposes. Your meta key will not be there. That is the whole story in one panel.

Trace the hook order with Query Monitor. If you have Query Monitor installed, its Hooks & Actions panel shows every callback on save_post for the request, in order, with the file and line that registered each one. If your callback appears but does not write, the bail condition is the cause. If it does not appear, it was never registered on that request — usually because it is hooked to save_post_{post_type} and the post type does not match, or because it is inside an is_admin() guard that the AJAX context satisfies differently than you assumed.

Check the post_updated comparison. The post_updated hook fires after the database write and passes $post_before and $post_after. The docs are clear that these objects contain post columns only — not meta. If you were relying on post_updated to detect a meta change, it will not. Use updated_post_meta for that, and note that it only fires when the meta row actually changes.

Why the other save paths behave differently

The classic editor’s post.php submit sends the full form, including every meta box’s nonce and fields. The block editor saves via the REST API, which sends a structured payload and fires save_post through wp_update_post() as well, but with a different $_POST shape — often empty, with data arriving as JSON in the request body. WP-CLI’s wp post update calls wp_update_post() directly with an array, so $_POST is empty. XML-RPC and imports go through wp_insert_post() with an array.

Four paths, one hook, four different $_POST realities. A callback that reads $_POST unconditionally is correct on exactly one of them. A callback that derives from the post object is correct on all of them. That is the design constraint, and Quick Edit is just the path that makes it obvious because its payload is the smallest.

The save_post docs also warn about infinite loops: if your callback calls wp_update_post(), unhook before and re-hook after. Quick Edit does not change that rule. It does make it easier to hit, because a callback that recomputes meta on every save and then calls wp_update_post() to store a derived column will recurse.

FAQ

Does Quick Edit fire save_post? Yes. wp_ajax_inline_save() calls edit_post(), which calls wp_update_post(), which fires save_post. The hook is not skipped. The payload is narrower than you expect.

Why does my nonce check fail on Quick Edit? Because Quick Edit does not send your nonce. It sends _inline_edit, verified against inlineeditnonce. Your meta box nonce only exists on the classic editor form. Check for the field before checking the nonce, or derive the value instead of reading it.

Can I add a custom field to Quick Edit? Yes, via quick_edit_custom_box to render the input and JS to populate it on row open. You must also read the field in your own save_post callback, because _wp_get_allowed_postdata() will strip it before wp_update_post() sees it.

Does Quick Edit delete my postmeta? No. It does not touch meta at all. Existing values persist unchanged. That is why the failure is silent — the old value is still there, just stale.

What about post_updated? It fires after the database write and gives you before/after post objects. Those objects contain post columns, not meta. If you need to detect a meta change, hook updated_post_meta instead.

Is there a way to see which callbacks ran? Query Monitor’s Hooks & Actions panel lists every callback on save_post for the request, with file and line. It is the fastest way to distinguish “callback did not run” from “callback ran and bailed.”

If you are still chasing a save path that does not behave, the same discipline applies: find the handler, read the payload, check the whitelist, verify at the database. The first-things-first checklist for a new install that returns nothing covers the adjacent case where the symptom is a blank page rather than a stale field, and the diagnostic order is the same.

Why Your Nav Menu Still Links to a Post You Trashed Last Week: nav_menu_item Rows in wp_posts, post_status Inheritance, and _menu_item_object_id That Never Gets Cleaned

You trashed a post. The menu still links to it. The link may 404, or it may resolve to the trash preview if you are logged in. Either way, the menu item is still there, still published, still pointing at an object that no longer exists in any public sense.

This is not a bug in the sense of a crash. It is a lifecycle mismatch. Menu items are posts. They live in wp_posts with post_type = 'nav_menu_item'. They reference their target through post meta, primarily _menu_item_object_id. When you trash the target, WordPress does not trash the menu item. When you permanently delete the target, WordPress does clean up the menu item — but only if the deletion path reaches the right hook. Trashing is the gap.

Symptom

A post is moved to Trash. The navigation menu still renders a link to it. The menu item appears in Appearance → Menus as if nothing happened. In the block editor’s Navigation block, the item may show as a link with a title but no resolved object, or it may silently disappear from the editor while remaining in the database and in classic menu output.

If you inspect the front end, the anchor’s href is still the old permalink. If the post is trashed, that permalink may return a 404 for anonymous visitors. If the post is later restored, the link works again. If the post is permanently deleted, the menu item may vanish from the menu — or it may not, depending on how the deletion was performed.

Trace

Start with the database. Menu items are rows in wp_posts:

SELECT ID, post_title, post_status, post_type, post_date
FROM wp_posts
WHERE post_type = 'nav_menu_item'
ORDER BY ID DESC
LIMIT 20;

Each menu item’s target is stored in wp_postmeta:

SELECT p.ID, p.post_title, p.post_status,
       oid.meta_value AS object_id,
       otype.meta_value AS object_type,
       otax.meta_value AS object_taxonomy
FROM wp_posts p
LEFT JOIN wp_postmeta oid   ON oid.post_id = p.ID   AND oid.meta_key = '_menu_item_object_id'
LEFT JOIN wp_postmeta otype ON otype.post_id = p.ID AND otype.meta_key = '_menu_item_object'
LEFT JOIN wp_postmeta otax  ON otax.post_id = p.ID  AND otax.meta_key = '_menu_item_object'
WHERE p.post_type = 'nav_menu_item'
ORDER BY p.ID DESC;

Now join against the target posts to find orphans:

SELECT mi.ID AS menu_item_id,
       mi.post_title AS menu_item_title,
       mi.post_status AS menu_item_status,
       oid.meta_value AS target_id,
       t.post_status AS target_status,
       t.post_type AS target_type
FROM wp_posts mi
JOIN wp_postmeta oid ON oid.post_id = mi.ID AND oid.meta_key = '_menu_item_object_id'
LEFT JOIN wp_posts t ON t.ID = oid.meta_value
WHERE mi.post_type = 'nav_menu_item'
  AND (t.ID IS NULL OR t.post_status = 'trash');

That query returns menu items whose target is either missing entirely or sitting in Trash. The first case is a permanent-deletion orphan. The second is the trash-inheritance case.

Why does the menu item survive trashing? Because wp_trash_post() changes the target post’s post_status to trash. It does not touch nav_menu_item rows. The menu item’s own post_status remains publish (or inherit in some older data). There is no core routine that walks wp_posts looking for nav_menu_item rows whose _menu_item_object_id matches the trashed post ID.

Permanent deletion is different. wp_delete_post() fires before_delete_post, delete_post, deleted_post, and the dynamic delete_post_{$post_type} / deleted_post_{$post_type} hooks. Core registers callbacks on those hooks for menu cleanup: _wp_delete_post_menu_item() and _wp_delete_tax_menu_item(). Those callbacks query for nav_menu_item rows referencing the deleted object and delete them. That is why permanent deletion usually cleans up the menu. Trashing does not, because trashing is not deletion.

The documentation for wp_delete_post() states that when a post is permanently deleted, “everything that is tied to it is deleted also,” including comments, post meta, and terms. It also notes that the function reverts to wp_trash_post() when $force_delete is false and the post type is post or page and the item is not already trashed. That revert is the fork in the road: the cleanup hooks fire on the permanent-delete path, not on the trash path.

At render time, wp_get_nav_menu_items() and wp_setup_nav_menu_item() resolve the referenced object. If the object no longer exists, the menu item may still be returned with a stale URL, or it may be filtered out depending on the code path and the menu item type. The key point is that resolution happens at render time, not at trash time. Nothing in the trash operation invalidates the menu item.

The REST API exposes menu items through /wp/v2/menu-items. Orphaned rows can appear there because the controller reads the same wp_posts and wp_postmeta data. The block editor’s Navigation block consumes that data. When the referenced object is missing, the block may render a fallback label or drop the item from the editor view, but the underlying row remains. This is why the classic menu screen and the block editor can disagree about what exists.

Root cause

Menu items are posts with their own lifecycle. Their relationship to the target object is stored in post meta, not in a foreign key. WordPress core cleans that relationship on permanent deletion, not on trashing. We found no documented core action or filter whose purpose is to clean menu items when a post is trashed. The deleted_post hook fires after permanent deletion; the trashed_post hook fires after trashing, but we found no evidence that core attaches menu cleanup to it.

The result is a predictable orphan class: nav_menu_item rows whose _menu_item_object_id points at a post in trash. They remain published, they remain in the menu, and they remain in the REST API. If the post is later restored, the link works again. If the post is permanently deleted, the cleanup hooks fire and the menu item is removed — unless the deletion happened through a path that bypasses those hooks, such as direct SQL or a custom deletion routine.

Fix

You have two choices: clean up on trash, or clean up on a schedule. Cleaning up on trash is more immediate but changes editorial behavior: a trashed post that is restored will lose its menu item. Cleaning up on a schedule preserves the restore path but leaves a window where the menu links to a trashed post.

If you want trash-time cleanup, hook trashed_post and delete the matching menu items. The following is a minimal implementation. It queries for nav_menu_item rows whose _menu_item_object_id matches the trashed post ID, then deletes them with wp_delete_post() using true for $force_delete so the menu item is permanently removed rather than trashed.

add_action( 'trashed_post', function ( $post_id ) {
    global $wpdb;

    $menu_item_ids = $wpdb->get_col( $wpdb->prepare(
        "SELECT p.ID
         FROM {$wpdb->posts} p
         JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID
         WHERE p.post_type = 'nav_menu_item'
           AND pm.meta_key = '_menu_item_object_id'
           AND pm.meta_value = %d",
        $post_id
    ) );

    foreach ( $menu_item_ids as $menu_item_id ) {
        wp_delete_post( (int) $menu_item_id, true );
    }
} );

If you prefer scheduled cleanup, run a WP-CLI command or a cron event that finds orphans and removes them. The query is the same as the audit query above. The deletion is the same wp_delete_post( $id, true ) call.

If you want to preserve the menu item across trash and restore, do not delete on trash. Instead, filter the menu output to skip items whose target is trashed. That keeps the row in the database and restores the link when the post is restored. The trade-off is that the orphan remains visible to anything reading the REST API or the database directly.

Verification

After applying a fix, verify with the audit query. It should return zero rows for the trashed post ID. Then check the front end: the menu should no longer link to the trashed post. Check the REST API:

curl -s 'https://example.com/wp-json/wp/v2/menu-items?per_page=100' \
  | jq '.[] | select(.meta._menu_item_object_id == "123")'

Replace 123 with the trashed post ID. If the response is empty, the menu item is gone from the REST surface. If you are using the block editor, open the Navigation block and confirm the item is no longer listed.

For a WP-CLI audit without writing a plugin, use wp db query:

wp db query "
SELECT mi.ID AS menu_item_id, mi.post_title, oid.meta_value AS target_id, t.post_status
FROM wp_posts mi
JOIN wp_postmeta oid ON oid.post_id = mi.ID AND oid.meta_key = '_menu_item_object_id'
LEFT JOIN wp_posts t ON t.ID = oid.meta_value
WHERE mi.post_type = 'nav_menu_item'
  AND (t.ID IS NULL OR t.post_status = 'trash');
"

If you want to delete the orphans returned by that query, pipe the IDs into wp post delete:

wp db query --skip-column-names "
SELECT mi.ID
FROM wp_posts mi
JOIN wp_postmeta oid ON oid.post_id = mi.ID AND oid.meta_key = '_menu_item_object_id'
LEFT JOIN wp_posts t ON t.ID = oid.meta_value
WHERE mi.post_type = 'nav_menu_item'
  AND (t.ID IS NULL OR t.post_status = 'trash');
" | xargs -r -n1 wp post delete --force

That command deletes the menu items permanently. It does not touch the trashed posts themselves. If you want to restore a trashed post and keep its menu item, do not run the deletion; run the audit and decide per item.

What about the block editor?

The Navigation block reads menu items through the REST API. When the referenced object is missing, the block’s client-side code may not render the item, but the server-side row remains. This is why the editor and the front end can disagree. The fix is the same: clean the row, or filter it out at render time. There is no block-editor-specific cleanup that runs on trash.

If you are debugging the block editor, check the network tab for /wp/v2/menu-items and inspect the response. If the orphan is present there, it is present in the database. If it is absent there but present in the classic menu, you are looking at a caching or capability difference, not a data difference.

FAQ

Does WordPress clean up menu items when a post is trashed?
No. Core cleans up menu items on permanent deletion through _wp_delete_post_menu_item() and related callbacks. Trashing changes the target’s post_status to trash and leaves the menu item untouched.

Why does the menu item still have post_status = 'publish'?
Because the menu item is its own post. Its status is independent of the target’s status. Trashing the target does not cascade to the menu item’s status.

What is _menu_item_object_id?
It is the post meta key that stores the ID of the object the menu item points to. For a post-type menu item, it is the post ID. For a taxonomy menu item, it is the term ID. The companion key _menu_item_object stores the object type, such as post or page.

Can I restore a trashed post and get the menu item back?
Yes, if you did not delete the menu item. The menu item row remains in the database, so restoring the post makes the link work again. If you delete the menu item on trash, restoring the post will not restore the menu item.

Does the REST API expose orphaned menu items?
Yes. The /wp/v2/menu-items endpoint reads from wp_posts and wp_postmeta. If the row exists, it can appear in the response. The block editor’s Navigation block consumes that endpoint, which is why orphans can surface there.

Is there a core filter for menu item cleanup on trash?
We found no documented core action or filter whose purpose is to clean menu items when a post is trashed. You can attach your own callback to trashed_post.

What about custom post types?
The same logic applies. Menu items can point to custom post types. Trashing a custom post type item does not clean the menu item. Permanent deletion does, provided the deletion goes through wp_delete_post() and the relevant hooks fire.

How do I find all orphaned menu items?
Use the audit query in the Verification section. It returns menu items whose target is missing or trashed. Run it with wp db query or directly in your database client.

Will this affect performance?
The audit query joins wp_posts and wp_postmeta. On a typical publishing site, the number of nav_menu_item rows is small. The query is not a concern at that scale. If you have thousands of menu items, add an index on meta_key and meta_value if your host allows it, or run the audit during low traffic.

What if I use a page builder or a custom menu system?
The underlying data model is the same if the menu is stored as nav_menu_item posts. If the builder stores its own menu data elsewhere, this article does not apply. Check the builder’s documentation for its own cleanup behavior.

Related reading

If you are debugging a fresh install where the front page says nothing found, the failure mode is different but the diagnostic habit is the same: trace the data, not the symptom. See What to Fix First When a New WordPress Site Says Nothing Found.

Sources

  • wp_delete_post() — documents the trash-versus-delete fork, the hooks that fire, and the cleanup of related data on permanent deletion.
  • deleted_post hook — fires immediately after a post is deleted from the database.
  • WP_Query — documents the query class used to retrieve posts, including menu items, and the parameters relevant to post type and status.
  • Block attributes — explains how block attributes are stored and parsed, which is relevant to how the Navigation block reads menu data.

Why Your Revision History Shrinks Overnight: wp_revisions_to_keep, the post_type=’revision’ Rows in wp_posts, and the postmeta That Revisions Never Carry

You open a post that had forty revisions yesterday. Today it has twelve. Nobody ran a cleanup plugin. Nobody touched the database. The number just dropped, and the oldest entries are gone. This is not a bug in the sense of a crash; it is the revision retention system doing exactly what it was told to do, at a moment you did not expect.

The symptom is almost always the same: a post’s revision count falls to a round number — 5, 10, 12 — and the surviving revisions are the most recent ones. The trace runs through wp_save_post_revision(), the wp_revisions_to_keep filter, and the post_type='revision' rows in wp_posts. The root cause is usually a filter or constant that changed the retention limit, combined with the fact that pruning is synchronous and silent. The fix is to find which layer set the limit, then verify against the database directly.

Symptom: the count drops without a scheduled event

WP-Cron does not delete revisions. There is no scheduled event in core that prunes revision rows. Pruning happens inside wp_save_post_revision(), which runs synchronously on post save, and inside wp_delete_post_revision() when a revision is explicitly deleted. If your revision count shrank, a save happened, and that save triggered the retention check.

This matters because the first instinct is to look at cron jobs, cleanup plugins, or database maintenance scripts. Those are the wrong places. The right place is the save path.

Trace: what wp_save_post_revision() actually does

The function is documented in the WordPress developer reference. The relevant sequence, per the source shown there, is:

  1. Bail if DOING_AUTOSAVE is defined and true.
  2. Bail if the post type does not support revisions.
  3. Bail if the post status is auto-draft.
  4. Bail if wp_revisions_enabled() returns false.
  5. Compare the current post against the latest stored revision. If nothing changed, return null.
  6. Insert the new revision via _wp_put_post_revision().
  7. Call wp_revisions_to_keep( $post ) to get the retention limit.
  8. If the limit is less than zero, return without deleting anything.
  9. Fetch all revisions in ascending order, apply the wp_save_post_revision_revisions_before_deletion filter, compute how many to delete, and delete the oldest ones — skipping any whose post_name contains autosave.

The deletion loop calls wp_delete_post_revision() for each old revision. That function deletes the revision row and its associated postmeta. It does not touch the parent post’s postmeta.

So the shrink is not a background process. It is the tail end of a save you or an editor performed, possibly hours before you noticed.

Root cause: the retention limit is set in more than one place

The number of revisions kept is determined by wp_revisions_to_keep(), which resolves in this order:

  1. The WP_POST_REVISIONS constant in wp-config.php, if defined.
  2. The wp_revisions_to_keep filter, which overrides the constant.

The developer reference for the filter states plainly: “Overrides the value of WP_POST_REVISIONS.” It also notes that by default, an infinite number of revisions are stored if a post type supports revisions. The filter callback receives the current number and the post object, and must return a value — if it returns nothing, revisions will be empty.

The wp-config.php documentation confirms the constant behavior: if you do not set WP_POST_REVISIONS, WordPress defaults it to true (enable post revisions). You can set it to false to disable revisions, or to an integer to cap the number stored.

Here is the failure mode. A site has define( 'WP_POST_REVISIONS', 12 ); in wp-config.php. That is the intended cap. But a plugin or theme adds a filter that returns a different number for certain post types. The filter wins. The editor sees twelve revisions on one post and five on another, and assumes the database is inconsistent. It is not. Two different limits are being applied to two different post types.

A second failure mode: the filter callback returns nothing. The documentation warns about this explicitly. If the callback does not return a value, the revisions will be empty — meaning the retention limit becomes zero or null, and the next save prunes everything except the current revision.

The rows: what a revision actually is in wp_posts

A revision is a row in wp_posts with post_type = 'revision'. The wp_get_post_revisions() function, per its source, merges these arguments into the query:

'post_parent' => $post->ID,
'post_type'   => 'revision',
'post_status' => 'inherit',

It defaults to order => 'DESC' and orderby => 'date ID'. The function also checks wp_revisions_enabled() unless check_enabled is set to false.

To see the actual rows for a post, run this SQL against your database. Replace the prefix and post ID as needed:

SELECT ID, post_parent, post_name, post_date, post_status, post_type
FROM wp_posts
WHERE post_parent = 1234
  AND post_type = 'revision'
ORDER BY post_date ASC, ID ASC;

The post_name column is the tell. Core revisions have names like 1234-revision-v1. Autosaves have names containing autosave. The deletion loop in wp_save_post_revision() skips autosaves when pruning, which is why you can sometimes see more rows than the retention limit suggests — autosaves are not counted the same way.

If you want to count revisions per post without loading them into memory, use WP-CLI:

wp post list --post_type=revision --post_parent=1234 --format=count

Or, for a broader audit across all posts:

wp db query "SELECT post_parent, COUNT(*) AS revisions
FROM wp_posts
WHERE post_type = 'revision'
GROUP BY post_parent
ORDER BY revisions DESC
LIMIT 20;"

The postmeta that revisions never carry

This is the part that surprises people. When a revision is created, WordPress copies the revisioned fields — the ones returned by _wp_post_revision_fields() — into the revision row. Those are typically post_title, post_content, post_excerpt, and a few others depending on the post type.

What it does not copy is the parent post’s postmeta. Custom fields, SEO metadata, template selections, and anything else stored in wp_postmeta are not versioned by core. If you restore a revision, you get the post fields back, but the postmeta stays as it was at the moment of restore — not as it was when the revision was saved.

This has a direct consequence for the shrink symptom. If you are counting revisions by querying wp_postmeta for a revision-related key, you are counting the wrong thing. Revisions do not carry the parent’s meta keys. They may have their own meta rows, but those are not a copy of the parent’s.

To verify what meta a revision actually has, run:

SELECT meta_key, meta_value
FROM wp_postmeta
WHERE post_id = 5678; -- the revision ID, not the parent

If that returns nothing, the revision has no meta. That is normal. It is not evidence of data loss.

The block editor and the REST autosave path

The block editor does not save revisions the same way the classic editor did. It uses the REST API, and autosaves go through WP_REST_Autosaves_Controller. The wp_get_post_revisions() documentation lists that controller as a consumer of the function, specifically for get_items().

Autosaves are stored as revisions with post_name containing autosave. They are subject to the same retention logic, but the deletion loop skips them. This means an editor who works in the block editor for a long session can accumulate autosave rows that are not pruned by the normal count. When they finally publish, a core revision is created, and the pruning runs against the non-autosave revisions.

If you are debugging a shrink and the editor uses the block editor, check whether the missing revisions were autosaves or core revisions. The SQL above distinguishes them by post_name.

Fix: find the layer that set the limit

Start with wp-config.php. Search for WP_POST_REVISIONS. If it is defined, note the value. If it is not defined, the default is true, which means unlimited revisions for post types that support them.

Next, search your plugins and theme for wp_revisions_to_keep. This filter can be added anywhere. A common pattern is a plugin that sets a different limit per post type:

add_filter( 'wp_revisions_to_keep', function( $num, $post ) {
    if ( 'post' === $post->post_type ) {
        return 5;
    }
    return $num;
}, 10, 2 );

If you find a filter like this, that is your limit. The constant is being overridden. To confirm, you can temporarily log the resolved value. Add this to a mu-plugin or a theme’s functions.php during debugging:

add_filter( 'wp_revisions_to_keep', function( $num, $post ) {
    error_log( sprintf(
        'Revisions to keep for post %d (%s): %d',
        $post->ID,
        $post->post_type,
        $num
    ) );
    return $num;
}, 9999, 2 );

Check your debug log after saving a post. The logged number is the effective limit. If it differs from what you expected, trace back to the filter or constant that set it.

If you want to raise the limit, do it in one place. Either set WP_POST_REVISIONS in wp-config.php or use the filter, not both. If you use the filter, make sure every callback returns a value. The documentation’s warning about empty revisions is not theoretical; a callback that returns nothing will cause the retention limit to collapse.

Verification: confirm the rows match the limit

After changing the limit, save a post a few times and then query the database. The count should stabilize at the new limit, plus any autosaves that were skipped.

SELECT COUNT(*) AS total,
       SUM(CASE WHEN post_name LIKE '%autosave%' THEN 1 ELSE 0 END) AS autosaves,
       SUM(CASE WHEN post_name NOT LIKE '%autosave%' THEN 1 ELSE 0 END) AS core_revisions
FROM wp_posts
WHERE post_parent = 1234
  AND post_type = 'revision';

If core_revisions is greater than your limit, check whether the wp_save_post_revision_revisions_before_deletion filter is altering the list. That filter, introduced in WordPress 6.2, receives the array of revisions before deletion and the post ID. A plugin can use it to exclude certain revisions from pruning.

If core_revisions is less than your limit, check whether the post type supports revisions at all. post_type_supports( $post->post_type, 'revisions' ) must return true. If it does not, no revisions are saved, and the count will be zero regardless of the limit.

What this is not

This is not a caching problem. Object cache and transient caches can make a count appear stale, but they do not delete rows. If the rows are gone from wp_posts, the deletion happened. If the rows are present but the count looks wrong, you are probably querying through a cached layer or counting the wrong post type.

This is also not a WP-Cron problem. There is no core cron event that prunes revisions. If a plugin registers one, that is a plugin behavior, not core behavior. Check wp_get_schedules() and your cron events if you suspect a plugin, but start with the save path.

FAQ

Does wp_delete_post_revision() delete the parent post’s postmeta?
No. It deletes the revision row and the revision’s own postmeta. The parent post’s meta is untouched.

Why do I see more revisions than my limit?
Autosaves are skipped during pruning. They are stored as revisions but are not counted against the limit in the same way. Check post_name for autosave.

Can I keep revisions forever?
Yes. Do not define WP_POST_REVISIONS and do not add a wp_revisions_to_keep filter that returns a finite number. The default is unlimited for post types that support revisions.

What happens if my filter callback returns nothing?
The documentation states that revisions will be empty. In practice, the retention limit collapses and the next save prunes aggressively. Always return a value from the callback.

Where is the retention limit resolved?
In wp_revisions_to_keep(), which applies the wp_revisions_to_keep filter to the value of WP_POST_REVISIONS. The filter overrides the constant.

Primary sources

If you are debugging a fresh install that returns nothing at all, the failure mode is different: what to fix first when a new WordPress site says nothing found covers the query and rewrite layer rather than the revision retention layer.

Why Mobile Downloads the 4MB Original: wp_calculate_image_srcset, Missing Intermediate Sizes, and the sizes Default

Your phone loads a 4MB JPEG on a 3G connection while the desktop browser gets a 180KB WebP. The HTML looks correct: an <img> with a src, a srcset, and a sizes attribute. The problem is not the markup. The problem is that wp_calculate_image_srcset() returned false, WordPress emitted the full-size URL as the sole src, and the browser had no candidates to choose from. This article traces that failure from the rendered tag back to _wp_attachment_metadata, through the core hooks that decide what goes into srcset, and ends with a verification procedure that does not require regenerating every thumbnail on the site.

Symptom

Open DevTools, switch to a mobile viewport, and inspect the image request. You see one of these patterns:

  • The <img> has a src pointing at the original upload (for example, 2026/09/hero.jpg) and no srcset attribute at all.
  • The <img> has a srcset with a single candidate, usually the full-size file, and a sizes attribute that claims the image will render at 100vw.
  • The srcset exists but contains only the thumbnail and medium candidates, so the browser picks the largest available and upscales it on a 1440px viewport.

In all three cases, the network panel shows the mobile device downloading a file that is several megabytes larger than necessary. The desktop browser may look fine because it has a wider viewport and the browser’s own heuristics pick a different candidate, or because the desktop cache already holds the original.

Trace

Start at the function that builds the attribute. wp_calculate_image_srcset() is documented as returning string|false, where false means an error or that only one source exists. The source code shows the early exits:

if ( empty( $image_meta['sizes'] ) || ! isset( $image_meta['file'] ) || strlen( $image_meta['file'] ) < 4 ) {
    return false;
}

That is the first place to look. If _wp_attachment_metadata has no sizes key, or the key is an empty array, the function bails before it ever inspects the requested dimensions. The wp_get_attachment_metadata() reference confirms the expected shape: sizes is an array keyed by size slug, and each value contains file, width, height, and mime-type. If that array is missing or malformed, srcset cannot be built.

If sizes is present, the function loops through each entry and applies several filters. The relevant ones for this failure are:

  • wp_calculate_image_srcset_meta — runs before the early exit, so a plugin can repair $image_meta here.
  • max_srcset_image_width — defaults to 2048, and excludes any candidate wider than that unless it is the src image.
  • wp_calculate_image_srcset — runs after the loop, and can add or remove candidates.

The loop also checks wp_image_matches_ratio(). Only candidates whose aspect ratio matches the requested size within one pixel are included. A cropped intermediate size with a different ratio is silently skipped. That is documented behavior, not a bug: srcset is for resolution switching, not art direction.

After the loop, the function checks $src_matched. If no candidate’s filename appears inside the src URL, the function returns false to avoid serving an incorrect image. This is the second common failure point. If the src points at a file that is not represented in sizes — for example, because the original was replaced or the metadata was written by a different process — the match fails and srcset is suppressed.

Finally, the function returns false if fewer than two sources remain after filtering. A single candidate is not a srcset; it is just a src with extra steps.

When wp_calculate_image_srcset() returns false, wp_get_attachment_image_srcset() returns false, and wp_img_tag_add_srcset_and_sizes_attr() does not add the attributes. The <img> keeps whatever src the block or theme supplied. In the block editor, that is often the full-size URL when the image block’s sizeSlug is unset or set to full.

Root cause

The root cause is almost always an incomplete sizes array in _wp_attachment_metadata. The array is written by wp_generate_attachment_metadata() during upload, and updated by wp_create_image_subsizes() and wp_update_image_subsizes(). If any of those steps fail, or if the registered sizes change after upload, the stored metadata no longer reflects the files on disk.

Three failure modes produce this state:

  1. The upload request timed out before sub-sizes were generated. The original file was written, the attachment post was created, but wp_generate_attachment_metadata() did not finish. The sizes key is missing or partial.
  2. The image editor could not process the file. WP_Image_Editor is abstract; the concrete implementation is WP_Image_Editor_GD or WP_Image_Editor_Imagick. If neither supports the mime type, or if the server lacks memory, multi_resize() returns a WP_Error and the sub-sizes are not recorded.
  3. The registered sizes changed after upload. A theme or plugin registered a new size, or the medium_size_w option was changed. Existing attachments do not automatically get the new size. wp_get_missing_image_subsizes() exists to compare stored metadata against currently registered sizes, but it only runs when something calls it — typically the admin media screen or a WP-CLI command.

There is also a subtler case: the sizes array is present and populated, but the src URL does not match any entry. This happens when the block editor stores a sizeSlug that no longer exists, or when the image was edited in WordPress and the metadata contains a hash suffix that the src does not include. The wp_calculate_image_srcset() source checks for the edit hash and filters out candidates from previous edits. If the src is from an older edit, no candidate matches and the function returns false.

Fix

Do not start by regenerating thumbnails. Start by reading the metadata. The following WP-CLI command prints the sizes array for a given attachment ID:

wp post meta get 1234 _wp_attachment_metadata --format=json | jq '.sizes'

If jq is not available, use wp eval:

wp eval '
$meta = wp_get_attachment_metadata( 1234 );
if ( empty( $meta["sizes"] ) ) {
    echo "sizes is empty or missing\n";
} else {
    foreach ( $meta["sizes"] as $slug => $data ) {
        printf( "%s: %dx%d (%s)\n", $slug, $data["width"], $data["height"], $data["file"] );
    }
}
'

Compare that output to the currently registered sizes:

wp eval 'print_r( wp_get_registered_image_subsizes() );'

wp_get_registered_image_subsizes() returns a normalized list of all currently registered sub-sizes, keyed by name. If a size is registered but absent from the attachment metadata, the file may or may not exist on disk. Check the uploads directory directly:

wp eval '
$meta = wp_get_attachment_metadata( 1234 );
$dir  = wp_get_upload_dir();
$path = trailingslashit( $dir["basedir"] ) . dirname( $meta["file"] );
foreach ( wp_get_registered_image_subsizes() as $slug => $size ) {
    if ( empty( $meta["sizes"][ $slug ] ) ) {
        echo "missing metadata: $slug\n";
    }
}
'

If the metadata is missing entries but the files exist, the fix is to update the metadata without re-encoding. wp_update_image_subsizes() is the core function for this, but it is defined in wp-admin/includes/image.php, so it is not loaded on the front end. A WP-CLI command can load it:

wp eval '
require_once ABSPATH . "wp-admin/includes/image.php";
$updated = wp_update_image_subsizes( 1234 );
if ( is_wp_error( $updated ) ) {
    echo $updated->get_error_message() . "\n";
} else {
    echo "updated\n";
}
'

If the files do not exist, wp_update_image_subsizes() will attempt to create them. That is the correct behavior, but it requires a working image editor and sufficient memory. If the server cannot create the files, the metadata will remain incomplete and srcset will continue to fail.

For a diagnostic view without modifying anything, use the wp_calculate_image_srcset_meta filter. It runs before the early exit and receives the raw $image_meta array. A temporary mu-plugin can log the array for a specific attachment:

add_filter( 'wp_calculate_image_srcset_meta', function( $image_meta, $size_array, $image_src, $attachment_id ) {
    if ( 1234 === $attachment_id ) {
        error_log( print_r( $image_meta, true ) );
    }
    return $image_meta;
}, 10, 4 );

That filter is also the correct place to repair inconsistencies in stored data, as the documentation states. If you need to add a candidate that is not in sizes, use wp_calculate_image_srcset instead. It receives the final $sources array and can append or remove entries. The hook reference includes an example that adds a custom header image source.

Do not use intermediate_image_sizes_advanced to fix this. That filter runs during upload and changes which sizes are generated. It does not repair existing metadata, and it does not affect wp_calculate_image_srcset() at render time.

Verification

After updating the metadata, verify the rendered output. The most direct check is to fetch the attachment’s srcset value:

wp eval '
$srcset = wp_get_attachment_image_srcset( 1234, "large" );
var_dump( $srcset );
'

A non-false string means the function found at least two matching candidates. If it returns false, the metadata is still incomplete or the src does not match any entry.

Next, inspect the actual HTML. Use curl with a mobile user agent, or open the page in a browser and view source. Look for the srcset attribute and confirm it contains multiple widths. The sizes attribute should also be present. If sizes is missing, the browser defaults to 100vw, which is often wrong for a content column. That default is not a WordPress bug; it is the HTML specification’s behavior when sizes is absent.

Finally, check the network request in DevTools. With a mobile viewport, the browser should request an intermediate size, not the original. If it still requests the original, check whether the src attribute itself points at the full-size file. The src is the fallback for browsers that do not support srcset, and it is also the image the browser uses if no candidate matches. In the block editor, the image block’s sizeSlug attribute controls which size is used for src. If it is set to full, the src will be the original even when srcset is present. The browser may still choose a smaller candidate from srcset, but the fallback is the large file.

To confirm the block editor’s stored value, inspect the post content:

wp post get 5678 --field=content | grep -o 'sizeSlug[^}]*'

If the block has no sizeSlug, WordPress uses the default. The default is not a fixed size name; it depends on the block’s attributes and the theme’s settings. In practice, an image block without an explicit size often falls back to full for the src, while srcset still offers smaller candidates. That is acceptable for modern browsers, but it means the src is not a small file. If you need the src itself to be an intermediate size, set the block’s size explicitly in the editor or filter the rendered block.

What not to do

Do not install a plugin that rewrites srcset before you have confirmed the metadata is complete. The core function is not broken; it is returning false because the data it needs is missing. A filter that forces a srcset string can produce candidates that do not exist on disk, which results in broken images on some viewports.

Do not regenerate all thumbnails as a first step. On a large library, that is a long-running operation that may time out and leave the site in a worse state. Use wp_get_missing_image_subsizes() to identify which attachments actually need work, and process them in batches.

Do not assume the problem is the sizes attribute. The sizes attribute tells the browser how wide the image will be displayed. It does not create candidates. If srcset is missing, sizes has nothing to select from.

FAQ

Why does the desktop browser load a small image but mobile loads the original?

Desktop browsers often have a wider viewport and a larger device pixel ratio, so they may select a larger candidate from srcset. If srcset is missing entirely, both desktop and mobile load the src. The difference you see may be caching: the desktop browser already has an intermediate size cached from a previous request, while mobile is requesting the src for the first time. Check the actual src and srcset attributes in the HTML, not just the network panel.

Does wp_calculate_image_srcset() include the full-size image?

Yes, unless the image is an animated GIF. The source code appends the full-size dimensions to the $image_sizes array before the loop, unless the thumbnail’s mime type is image/gif. For GIFs, the behavior differs to avoid hiding animation. For JPEG, PNG, and WebP, the full-size image is a candidate, but it is subject to the max_srcset_image_width filter, which defaults to 2048 pixels. If the original is wider than 2048 and is not the src image, it is excluded from srcset.

Can I fix this without WP-CLI?

Yes. The same functions are available in the admin. The media library’s edit screen may offer a regenerate option depending on your setup, but the core function wp_update_image_subsizes() is not exposed as a button by default. A small admin-side script or a one-time wp eval command is usually faster than clicking through the media library. If you prefer a plugin, choose one that calls the core functions rather than reimplementing them.

What if the metadata is correct but srcset is still missing?

Check the src URL against the file values in sizes. The function requires a match. If the src points at a CDN URL or a resized version that is not in the metadata, the match fails. The wp_calculate_image_srcset_meta filter can be used to normalize the metadata, or the wp_calculate_image_srcset filter can be used to add the missing source. Both are documented hooks, and both are preferable to filtering the final HTML.

Does the REST API affect this?

The REST API’s attachments controller uses wp_get_attachment_metadata() and wp_get_registered_image_subsizes() when preparing responses and validating image dimensions. If the metadata is incomplete, the REST response may omit size information, and the block editor may not offer the missing sizes in the image block’s size dropdown. That is a symptom, not a cause. Fixing the metadata fixes the REST response.

What about WP-Cron?

WP-Cron does not generate image sub-sizes by default. It may be used by plugins to schedule background processing, but core’s upload flow generates sub-sizes synchronously during the upload request. If the upload request times out, WP-Cron is not a fallback. The wp_generate_attachment_metadata() function runs in the same request that handles the upload. If that request is killed, the metadata is incomplete and no scheduled event will repair it unless a plugin explicitly schedules one.

For a related failure mode where a new site returns no results because the query is looking for content that was never published, see What to Fix First When a New WordPress Site Says Nothing Found. The diagnostic pattern is similar: read the stored data before changing the rendering layer.

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.