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.