⏲️ Estimated reading time: 12 min
Table of Contents
Want your WordPress Trash to clean itself automatically? This guide shows two safe options: a simple core setting that empties Trash after X days, plus a more advanced MU-plugin that can target specific post types, run on a schedule, and log deletions for peace of mind.
- Why Auto-Deleting Trashed Posts Matters
- How WordPress Trash Works Behind the Scenes
- What Happens If You Never Empty the Trash
- When You Should Not Auto-Delete Trash
- The Two Best Ways to Auto-Delete Trashed Posts
- Option 1: Use WordPress Core Setting (Fastest and Safest)
- Option 2: Use a Custom PHP Cleaner (More Control)
- Option 1: Set WordPress to Empty Trash After X Days
- The Easiest Solution:
EMPTY_TRASH_DAYS - Where to Add It
- Example: Empty Trash After 7 Days
// wp-config.php (best place)
define('EMPTY_TRASH_DAYS', 7);
- What This Method Deletes
- Pros of Using
EMPTY_TRASH_DAYS - Cons of Using
EMPTY_TRASH_DAYS - Common Mistakes With
EMPTY_TRASH_DAYS - Option 2: Advanced MU-Plugin Cleaner (Target Posts Only)
- Why Use an MU-Plugin for This
- What This MU-Plugin Will Do
- Safety Principles This Code Follows
- The Complete PHP Code
<?php
/**
* Plugin Name: HZ Auto Delete Trashed Posts After X Days (MU)
* Description: Permanently deletes trashed posts older than X days using a safe WP-Cron job. Supports post type filtering and logging.
* Author: Tokyo Blade
* Version: 1.0.0
*
* Install as MU-plugin:
* /wp-content/mu-plugins/hz-auto-delete-trashed-posts.php
*/
if (!defined('ABSPATH')) {
exit;
}
class HZ_Auto_Delete_Trashed_Posts {
const CRON_HOOK = 'hz_delete_trashed_posts_cron_hook';
/**
* Configuration
* Change these values to match your needs.
*/
public static function config(): array {
return [
// Delete items that have been in trash for at least this many days.
'days' => 14,
// Which post types should be cleaned?
// Use ['any'] to include all public post types (still only deletes those in trash).
'post_types' => ['post', 'page'],
// Limit deletions per run to avoid timeouts on large sites.
'batch_size' => 50,
// If true, the plugin will NOT delete anything; it will only log what it would delete.
'dry_run' => false,
// Enable simple logging to wp-content/debug.log (requires WP_DEBUG_LOG true)
'log' => true,
];
}
public static function init(): void {
add_action('init', [__CLASS__, 'maybe_schedule']);
add_action(self::CRON_HOOK, [__CLASS__, 'run_cleanup']);
}
public static function maybe_schedule(): void {
if (!wp_next_scheduled(self::CRON_HOOK)) {
// Schedule daily; WordPress will run it when someone visits the site (WP-Cron behavior).
wp_schedule_event(time() + 300, 'daily', self::CRON_HOOK);
}
}
private static function log(string $message): void {
$cfg = self::config();
if (empty($cfg['log'])) {
return;
}
if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
error_log('[HZ Trash Cleaner] ' . $message);
}
}
/**
* Determine when a post was moved to trash.
* WordPress stores trash metadata in _wp_trash_meta, including a unix timestamp in 'time'.
*/
private static function trashed_timestamp(int $post_id): ?int {
$meta = get_post_meta($post_id, '_wp_trash_meta', true);
// _wp_trash_meta is usually an array like ['status' => 'publish', 'time' => 1700000000]
if (is_array($meta) && isset($meta['time'])) {
$t = (int) $meta['time'];
return $t > 0 ? $t : null;
}
// Fallback: use modified time if trash meta is missing for some reason.
$modified_gmt = get_post_field('post_modified_gmt', $post_id);
if (!empty($modified_gmt) && $modified_gmt !== '0000-00-00 00:00:00') {
$ts = strtotime($modified_gmt . ' GMT');
return $ts ?: null;
}
return null;
}
private static function resolve_post_types(array $requested): array {
if (in_array('any', $requested, true)) {
$types = get_post_types(['public' => true], 'names');
// Keep it safe: ignore attachments here unless you explicitly want them.
unset($types['attachment']);
return array_values($types);
}
return array_values(array_filter($requested));
}
public static function run_cleanup(): void {
$cfg = self::config();
$days = max(1, (int) ($cfg['days'] ?? 14));
$batch = max(1, min(500, (int) ($cfg['batch_size'] ?? 50)));
$dry_run = !empty($cfg['dry_run']);
$post_types = self::resolve_post_types((array) ($cfg['post_types'] ?? ['post', 'page']));
// Cutoff timestamp: anything trashed before this moment should be deleted
$cutoff = time() - ($days * DAY_IN_SECONDS);
self::log("Cleanup started. days={$days}, batch_size={$batch}, dry_run=" . ($dry_run ? 'true' : 'false') . ", cutoff=" . gmdate('Y-m-d H:i:s', $cutoff) . " GMT");
$q = new WP_Query([
'post_type' => $post_types,
'post_status' => 'trash',
'posts_per_page' => $batch,
'fields' => 'ids',
'orderby' => 'date',
'order' => 'ASC',
'no_found_rows' => true,
'ignore_sticky_posts' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
]);
if (empty($q->posts)) {
self::log('No trashed posts found in this batch.');
return;
}
$deleted = 0;
$skipped = 0;
foreach ($q->posts as $post_id) {
$trashed_at = self::trashed_timestamp((int) $post_id);
if (!$trashed_at) {
$skipped++;
self::log("Skipped post_id={$post_id} (cannot determine trash time).");
continue;
}
if ($trashed_at > $cutoff) {
$skipped++;
self::log("Skipped post_id={$post_id} (trashed_at=" . gmdate('Y-m-d H:i:s', $trashed_at) . " GMT, still within {$days} days).");
continue;
}
$title = get_the_title($post_id);
$type = get_post_type($post_id);
if ($dry_run) {
$deleted++;
self::log("DRY RUN: Would delete post_id={$post_id}, type={$type}, title=\"" . $title . "\"");
continue;
}
// wp_delete_post($id, true) => force delete (bypass trash)
$result = wp_delete_post($post_id, true);
if ($result) {
$deleted++;
self::log("Deleted post_id={$post_id}, type={$type}, title=\"" . $title . "\"");
} else {
$skipped++;
self::log("Failed deleting post_id={$post_id} (wp_delete_post returned false).");
}
}
self::log("Cleanup finished. deleted={$deleted}, skipped={$skipped}");
}
}
HZ_Auto_Delete_Trashed_Posts::init();
What Each Part of the Code Does
Plugin Header and MU-Plugin Purpose
The header block at the top lets WordPress recognize the file as a plugin. Because we place it in mu-plugins, it loads automatically for every request, without needing activation from the admin panel.
That’s perfect for maintenance tasks like Trash cleanup because:
- you won’t forget to activate it after migrations,
- theme updates can’t remove it,
- it runs consistently.
The Configuration Array
Inside config() you control everything in one place:
days: how long an item can stay in Trash before permanent deletion.post_types: which content types to clean.batch_size: how many to delete in a single run.dry_run: safe testing mode (logs only).log: sends messages todebug.logwhen enabled.
This design keeps your code clean and reduces “hunt and replace” edits across the file.
Scheduling With WP-Cron
maybe_schedule() checks if your cleanup event exists:
- if not, it schedules a daily event.
WordPress WP-Cron runs when your site gets traffic. That means:
- no traffic = no cron runs,
- but most sites get enough visits for daily scheduling to work fine.
If your site is very low traffic, you can switch to a real server cron later. The plugin still works.
Finding “When It Was Trashed”
This is the most important part.
WordPress stores trash metadata in a post meta key called:
_wp_trash_meta
It usually contains:
- the original status (publish/draft),
- a timestamp called
time(when the post moved to trash).
The function trashed_timestamp() reads that value, and if it fails, it uses a fallback:
post_modified_gmt
That fallback protects you from edge cases like:
- older imports,
- custom scripts that trashed items without writing meta,
- unusual post types.
Querying Only Trashed Items
WP_Query is set to:
post_status => 'trash'post_type => ['post','page'](or whatever you set)posts_per_page => batch_size
We also set performance flags:
no_found_rows => true- meta and term caches disabled for speed
The goal is simple:
- do less work every day,
- keep the site fast.
Cutoff Logic
We compute:
cutoff = now - (days * DAY_IN_SECONDS)
If the item was trashed before the cutoff:
- we delete it permanently.
If not:
- we skip it.
That is the exact behavior people usually mean by “delete trashed posts after X days”.
Permanent Deletion
This line does the real job:
wp_delete_post($post_id, true);
The true means:
- force delete (bypass Trash completely)
So the post is removed from the database and can’t be restored from Trash anymore.
Logging and Dry Run Mode
If dry_run is enabled, the plugin logs:
- what it would delete,
but it never deletes anything.
This is how you should test on a real site:
- set
dry_run => true - wait for a cron run (or manually trigger WP-Cron)
- check the logs
- set
dry_run => false
How to Install This on Your WordPress Site
Step 1: Create the MU-Plugins Folder
Go to:
wp-content/
If you don’t see mu-plugins, create it:
wp-content/mu-plugins/
Step 2: Create the Plugin File
Create:
wp-content/mu-plugins/hz-auto-delete-trashed-posts.php
Paste the code inside.
Step 3: Enable WordPress Debug Log (Optional but Recommended)
In wp-config.php:
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
Then check:
wp-content/debug.log
Step 4: Test Using Dry Run
Change:
'dry_run' => true
Let it run once, confirm logs, then set:
'dry_run' => false
How to Customize It for Your Site
Change the Number of Days
Set:
'days' => 7(or whatever)
Delete All Public Post Types
Use:
'post_types' => ['any']
This includes public post types but excludes attachment by default for safety.
Include a Custom Post Type
Example:
'post_types' => ['post', 'page', 'product']
Increase Batch Size Carefully
For large sites, increase slowly:
'batch_size' => 100
If your hosting is weak, keep it smaller.
Pros and Contra
✅ Benefits of Auto-Deleting Trashed Posts
- keeps your database cleaner over time
- reduces clutter in the admin
- prevents huge trash piles after bulk edits
- lowers risk of restoring old junk by accident
⚠️ Downsides to Consider
- once deleted, you can’t restore from Trash
- a too-small “days” value can remove content sooner than expected
- WP-Cron timing depends on traffic unless you use a real cron
Comparison Table: Core Setting vs MU-Plugin
| Feature | EMPTY_TRASH_DAYS | MU-Plugin Cleaner |
|---|---|---|
| Setup difficulty | Very easy | Medium |
| Controls post types | No | Yes |
| Logging | No | Yes |
| Dry run testing | No | Yes |
| Batch limit | No | Yes |
| Best for | Most websites | Power users / big sites |
Practical Recommendations
Best Settings for Most Blogs
- Use
EMPTY_TRASH_DAYS = 14or30 - Keep it simple unless you need post type control
Best Settings for Busy Content Sites
- Use MU-plugin
- Set
daysto 7–21 - Set batch size to 50–150
- Enable dry run first
Best Settings for WooCommerce Sites
Be careful with custom post types like:
shop_order,product,shop_coupon
If you include them, double-check your workflow. Many stores don’t want automated deletions for orders, even in trash.
Frequently Asked Questions
How many days should I keep posts in Trash?
Most sites do well with 14–30 days. If you publish daily and delete often, 7–14 days can work. If you rarely delete content, keep 30 days.
Is EMPTY_TRASH_DAYS enough for most websites?
Yes. It’s the simplest and most stable option because WordPress supports it directly.
Will this delete media attachments too?
The MU-plugin does not include attachment when you use any. If you add attachment manually, it can permanently delete trashed media.
Can I restore posts after the MU-plugin deletes them?
No. It uses forced deletion. Once removed, you’d need a backup to restore.
Does WP-Cron run exactly daily?
Not always. WP-Cron runs when visitors load pages. On low-traffic sites, it may run late.
Can I run this with a real server cron instead of WP-Cron?
Yes. Many admins disable WP-Cron and use server cron to call wp-cron.php. The MU-plugin still works.
Will this break my site?
The approach is safe because it targets only post_status = trash and checks a trash timestamp cutoff before deletion.
Can I exclude specific posts from deletion?
This code doesn’t include exclusions by ID yet. If you want, you can add an “exclude IDs” array and skip them in the loop.
Can I clean only pages and not posts?
Yes. Set:
'post_types' => ['page']
What if _wp_trash_meta is missing?
The plugin falls back to post_modified_gmt. If it can’t determine a timestamp, it skips the post to stay safe.

Auto-deleting Trash is one of those “small” maintenance tweaks that quietly keeps WordPress healthy. If you want the quickest win, set EMPTY_TRASH_DAYS and forget about it. If you want full control, the MU-plugin approach gives you scheduling, batching, and logging without adding a heavy plugin.
⚠️ Disclaimer and Source Hygiene
This article is for educational purposes and general site maintenance guidance. Always test changes on a staging site first and keep reliable backups. WordPress setups vary by theme, plugins, hosting, and caching layers. Recommendations here are based on practical development patterns and standard WordPress behavior.
🔔 For more tutorials like this, consider subscribing to our blog.
📩 Do you have questions or suggestions? Leave a comment or contact us!
🏷️ Tags: WordPress, WordPress PHP, MU-plugin, WP-Cron, Trash cleanup, Database optimization, WordPress maintenance, Delete trashed posts, wp-config, Site performance
📢 Hashtags: #WordPress #WordPressTips #WordPressPHP #WPDeveloper #MUPlugin #WPCron #WebsiteMaintenance #WPPerformance #BloggingTips #TechTutorial
📚 Sources and References
WordPress Developer Concepts Used
Practical Notes From Real-World WordPress Maintenance
Suggested Reading Inside WordPress Admin