A site we took over last year had a page that 404’d for about three weeks before anyone said anything. It wasn’t hacked, it wasn’t a redirect problem, and the page was sitting right there in the admin where it always had been. Someone had trashed it by accident, noticed within the hour, and clicked Restore. WordPress put it back — as a draft. The row returned, the title returned, the URL kept pointing at nothing.
That behavior isn’t a bug, and it isn’t new. It’s been WordPress’s documented default since version 5.6. But almost nobody knows about it, because the Trash looks like the one part of WordPress that works exactly the way you’d guess: things go in, things come out, nothing is lost. Under that friendly recycle-bin metaphor there’s a real state machine that renames your slugs, reassigns your post statuses, quietly holds onto comments, and runs a deletion timer that most site owners have never heard of.
This guide walks through what actually happens to a post between “Move to Trash” and gone forever — the database rows, the slug rename, the thirty-day clock, and the one category of content WordPress deletes permanently the moment you click, with no trash and no undo.
What the Trash Actually Does to a Post
The Trash arrived in WordPress 2.9, and it’s less of a folder than a status. When you trash a post, nothing moves and nothing is copied. WordPress runs wp_trash_post(), which flips the post’s post_status column to trash and leaves the row exactly where it was in wp_posts.
The row stays put for as long as it’s trashed
Every trashed post keeps its ID, its full content, its meta, and its revisions. The Trash view in wp-admin is just a query filtered to that one status. This matters more than it sounds: a site with two thousand trashed posts is carrying two thousand full posts’ worth of content, postmeta, and revision history in the tables that every front-end query has to work around.
Before the status flip, core writes two pieces of status metadata to the post so it can find its way home later:
_wp_trash_meta_status— the status the post had immediately before it was trashed_wp_trash_meta_time— a Unix timestamp of the moment it went in, which is what the deletion timer reads
Comments get pulled in too. Core caches each comment’s approval status in a meta key of its own and then rewrites every comment on that post to the status post-trashed, so they vanish from your moderation queue without being deleted. Restore the post and core puts them back to the statuses it cached, with one guard: a comment whose cached status was itself post-trashed comes back as unapproved.
Your slug gets renamed out from under you
This is the part that surprises developers. A trashed post can’t be allowed to squat on its URL, because you might want to republish something at that address. So core appends a suffix, and your slug annual-report becomes annual-report__trashed in the database.
Before it does that, it stores the original in a meta key called _wp_desired_post_slug. Core’s own comment on that function says the slug is saved so the post can “try to reclaim it” on the way back out — and the hedge in that sentence is doing real work, as we’ll get to. The rename also truncates: the slug is cut to 191 characters before the nine-character suffix is added, which keeps the result inside the 200-character column.
Trashing is reversible, but it is not neutral. A round trip through the Trash can change a post’s status and change its URL — two edits you didn’t ask for and won’t see reflected anywhere on the edit screen — and it hides the post’s comments for as long as it sits there. On a live site, the status change alone is enough to take a working page offline while the post still looks perfectly normal in the admin.
The Restore That Doesn’t Restore
Click Restore and WordPress calls wp_untrash_post(). It reads back the previous status it saved, fires its hooks, deletes the two trash meta keys, and updates the post. The thing it does not do is put the post back the way it was.
Since 5.6, restored posts come back as drafts
Core sets the new status to draft for everything except attachments, which return to inherit. Your previously published post is now an unpublished draft. WordPress’s own function reference documents the change plainly: as of 5.6, “an untrashed post is now returned to ‘draft’ status by default, except for attachments which are returned to their original ‘inherit’ status.” Before 5.6, restored posts were always given their original status back.
There was a sensible reason for the change. Restoring straight to publish means one misclick in the Trash view instantly pushes content live, sometimes content that was trashed precisely because it shouldn’t be public. Defaulting to draft makes restoration a two-step, deliberate act. It also means that on any site where an editor restores something and assumes they’re done, the page stays dark until someone hits Publish.
If you’d rather have the old behavior, core ships the filter and even ships the callback for it. The wp_untrash_post_status filter receives the new status, the post ID, and the previous status, and core includes a ready-made function that just returns the third argument:
// Restore posts to the status they had before being trashed.
add_filter(
'wp_untrash_post_status',
'wp_untrash_post_set_previous_status',
10,
3
);
Put that in a small site-specific plugin rather than your theme’s functions.php, so that switching themes later doesn’t silently take the behavior with it.
And the slug is only probably yours again
On the way out of the Trash, core looks for that _wp_desired_post_slug value, deletes the meta, and hands the old slug back to the post. Then it runs the result through wp_unique_post_slug() like any other save — which is where the reclaim can fail. If something else grabbed annual-report while your post was in the Trash, your restored post gets annual-report-2 instead, and the original URL now belongs to a different piece of content.
Core does try to keep that from happening. When you publish or update a post whose slug matches a trashed post’s slug, core goes and finds those trashed posts and suffixes them out of the way first. The add_trashed_suffix_to_trashed_posts filter, which defaults to true, is there if you ever need to switch that off. Core resolves the collision in favor of the live post, which is the right call — but it’s also the moment your trashed post loses the clean version of that address. It will still try to reclaim the slug on restore; it just won’t win the race.
The Thirty-Day Timer You Never Set
Trashed content doesn’t sit there forever. WordPress has been quietly deleting it on a schedule the entire time.
EMPTY_TRASH_DAYS, and what it really controls
The default retention window is 30 days. It’s set by a constant called EMPTY_TRASH_DAYS, which WordPress defines for you if your wp-config.php doesn’t. The official wp-config documentation describes it as “the number of days before WordPress permanently deletes posts, pages, attachments, and comments, from the trash bin.”
You can change it to any number of days, and setting it to zero disables the Trash altogether — every delete becomes immediate and permanent. The docs attach a warning to that one worth repeating: with the Trash disabled, WordPress “will not ask for confirmation when someone clicks on ‘Delete Permanently’ using this setting.” Zero is a legitimate choice for a tightly controlled site, but it removes your last line of defense against a slip of the mouse.
// In wp-config.php, above the "That's all, stop editing!" line.
define( 'EMPTY_TRASH_DAYS', 30 ); // Default: 30 days
define( 'EMPTY_TRASH_DAYS', 7 ); // Tighter window
define( 'EMPTY_TRASH_DAYS', 0 ); // Disable the Trash entirely
The cleanup runs on WP-Cron, which means “daily” is aspirational
The actual deletion is handled by a hook named wp_scheduled_delete, registered as a daily WP-Cron event. When it fires, it queries the postmeta table for every _wp_trash_meta_time older than your cutoff, confirms each post is still in the Trash, and calls wp_delete_post() on it. That deletes for good rather than re-trashing, because a post already sitting in the Trash skips the trash-instead-of-delete guard. It does the same pass over trashed comments. Anything whose status changed without going through a proper restore gets its stale trash meta cleaned up instead of being deleted. A normal restore is safer still: wp_untrash_post() deletes those trash meta keys itself, so the cleanup job stops matching the post at all.
Two details about that schedule are worth knowing. The event is registered from wp-admin/admin.php, so it gets scheduled the first time somebody loads an admin page. And WP-Cron isn’t a real cron daemon — it only runs when someone visits the site. On a quiet site the cleanup is delayed until the next page load, not skipped, so trashed content can outlive its window by a while. If you want it punctual, disable WP-Cron in wp-config.php and hit wp-cron.php from a real system cron job.
You can see the whole schedule, including this one, from the command line:
wp cron event list --fields=hook,next_run_relative,recurrence
While you’re in there, you’ll spot a sibling event called wp_scheduled_auto_draft_delete. That one is unrelated to the Trash: it force-deletes abandoned auto-drafts — the empty placeholder posts WordPress creates the instant you open the editor — once they’re more than seven days old.
The two constants that govern all of this, and their core defaults:
EMPTY_TRASH_DAYS — default 30. Days before trashed posts, pages and comments are permanently deleted. Set to 0 to switch the Trash off entirely.
MEDIA_TRASH — default false. When false, deleting an attachment is immediate and permanent. Set to true to give media a Trash of its own.
Attachments Play by Different Rules
Here’s the asymmetry that catches people out. Posts and pages get a thirty-day safety net by default. Images, PDFs and every other file in your Media Library get none.
MEDIA_TRASH is off unless you turn it on
Core defines MEDIA_TRASH as false. With it off, deleting an attachment skips the Trash entirely: the row goes, the postmeta goes, and the file is unlinked from disk. There is no restore, and no scheduled job to intercept — your only recovery path is a backup.
Turning it on is one line in wp-config.php, and it makes the Media Library behave like the posts list, with a Trash view and the same retention window:
define( 'MEDIA_TRASH', true );
Note that attachments need both constants to be truthy before they’ll go to the Trash. If you’ve set EMPTY_TRASH_DAYS to zero to disable the Trash, enabling MEDIA_TRASH on its own won’t give you a media Trash.
What to Actually Do About It
Three decisions. The first two stand on their own; the third only takes effect if you’ve left EMPTY_TRASH_DAYS above zero.
1. Pick a retention window that matches how your team works
Thirty days is generous for a site where one person publishes and nobody deletes much. It’s not generous at all for a site with several editors, where a mistake might not surface until the next quarterly review. If your content is mostly evergreen and your team is small, a shorter window like seven days keeps the tables tidy. If people are actively cleaning house, leave it at 30 or raise it, and lean on your backups for anything older.
2. Empty the Trash on purpose instead of waiting
Deleting a post is a real database operation, not a flag flip. Force-deleting removes the post row, its postmeta, its revisions, its comments and their meta, and its term relationships. Doing thousands of those in a single admin request is how people time out the Empty Trash button. WP-CLI handles it without the browser in the middle:
# See what you're carrying, by post type.
wp post list --post_status=trash --post_type=any \
--fields=ID,post_type,post_title --format=count
# Look at the oldest ones before you commit to anything.
wp post list --post_status=trash --post_type=any \
--fields=ID,post_type,post_title,post_modified --format=table
# Permanently delete every trashed post. Take a backup first.
wp post delete $(wp post list --post_status=trash \
--post_type=any --format=ids) --force
Run the count first. On sites we’ve inherited, the number that comes back is routinely in the thousands, and it’s almost always a mix of genuine cleanup and one afternoon where somebody bulk-trashed an old import. Read the list before you delete it, because the --force flag means exactly what it says.
3. Decide whether your Media Library deserves a net
If the people managing your media are the same people who write the content, MEDIA_TRASH is cheap insurance and the reason is obvious the first time somebody clears out “old” images that turned out to be in use. If your library is machine-generated — product imports, form uploads, anything that churns — a permanent delete may be exactly what you want, and the Trash just becomes another pile to manage.
None of this replaces a backup
The Trash protects you from a misclick. It does nothing about a bad plugin update, a failed migration, or a hack, and its thirty-day window is short enough that the mistake you find in March is already unrecoverable from February. Treat it as an undo button with a timer, and keep real backups underneath it.
Count what’s in your Trash. If a post came back from it recently, check that it’s actually published and that its URL still resolves. Set EMPTY_TRASH_DAYS deliberately instead of inheriting 30. And if losing a Media Library file would ruin your week, add MEDIA_TRASH before you need it, not after — and keep EMPTY_TRASH_DAYS above zero, or it won’t do a thing.
Frequently Asked Questions
If a page on your site went quiet and nobody can explain why, the Trash is worth checking before anything else. We audit and clean up WordPress installations for a living — talk to amplifi.studio.
Built by amplifi.studio — see also WordPress Kept Every Draft You Ever Saved: A WordPress Post Revisions Guide.