Deleting a handful of WordPress posts from the admin dashboard is easy. Deleting 20,000, 50,000, or more trashed posts from a production WordPress installation is a different kind of job.
The WordPress admin interface has to process browser requests, load admin screens, run database queries, execute plugin hooks, and work within PHP and web-server timeout limits. When Trash contains tens of thousands of posts – especially a custom post type – WP-CLI is usually a much better tool.
However, the safe approach is not simply to run a large wp post delete command and hope for the best.
Before deleting anything, you should establish exactly which website, Linux user, PHP runtime, WordPress site, custom post type, post status, and records you are targeting. The actual deletion should be the last step, not the first.
What This Tutorial Covers
This tutorial explains how to safely permanently delete a large number of trashed WordPress posts or custom post type entries from a VPS using WP-CLI.
- Connect to the VPS through SSH.
- Find the Linux user that owns the website.
- Run WP-CLI as the correct website user.
- Confirm the PHP version used by WP-CLI.
- Match WP-CLI to the PHP version used by the website.
- Optionally create a database backup when you do not already have a current verified backup.
- Confirm the custom post type slug.
- Optionally target the correct site in a WordPress Multisite network.
- Count exactly how many trashed posts will be affected.
- Inspect a batch before deleting anything.
- Delete the posts in controlled batches.
- Verify that the intended Trash is actually empty afterward.
The examples use Linux, WP-CLI, WordPress, and an OpenLiteSpeed/CyberPanel-style server. The same general approach applies to many Apache, Nginx, LiteSpeed, and managed VPS environments.
Example Scenario
Assume a WordPress website contains a custom post type with the human-readable label “Stories”. WordPress reports more than 20,000 Stories in Trash, and we want to permanently remove only those trashed Stories.
We do not want to delete published Stories, drafts, pending Stories, another post type, or content belonging to the wrong site in a Multisite network.
For the examples below, assume WordPress is installed at:
/home/example.com/public_html/
and the website URL is:
https://example.com/
Replace these example values with the actual values from your server.
Step 1 – Connect to the VPS
Connect to the server through SSH:
ssh root@SERVER_IP
Then move into the WordPress installation:
cd /home/example.com/public_html/
Confirm your current location:
pwd
You should see the expected WordPress directory:
/home/example.com/public_html
This check matters because WP-CLI normally discovers WordPress from the current working directory unless you explicitly use --path. Running a destructive command from the wrong directory can make WP-CLI operate on a different WordPress installation.
Step 2 – Find the Website’s Linux User
Many VPS control panels create a separate Linux user for each website. CyberPanel commonly works this way.
Do not guess the username. Check the ownership of a file that belongs to the WordPress installation:
stat -c '%U:%G' wp-config.php
Example output:
siteuser1234:siteuser1234
In this command, %U prints the owning username and %G prints the owning group.
You can inspect the directory and configuration file ownership in more detail:
ls -ld .
ls -l wp-config.php
The directory group and the file group do not always need to match. What matters at this stage is identifying the Linux account that owns and normally manages the site’s files.
Step 3 – Switch to the Website User
You may be connected as root, but routinely running WordPress maintenance as root is not ideal.
Switch to the website-specific Linux user:
su -s /bin/bash siteuser1234
Then confirm your user:
whoami
Expected output:
siteuser1234
Move back to the WordPress directory if necessary:
cd /home/example.com/public_html/
Running WP-CLI as the site owner reduces the risk of creating root-owned cache files, generated files, temporary files, or other artifacts inside a website that normally belongs to another Linux user.
Step 4 – Confirm That WP-CLI Works
Before doing anything destructive, inspect the WP-CLI environment:
wp --info
The output normally includes the operating system, shell, PHP binary, PHP version, PHP configuration file, MySQL or MariaDB client, and WP-CLI version.
Then confirm that WP-CLI can load WordPress:
wp core version
If the command returns the installed WordPress version, WP-CLI has successfully located and loaded the installation.
Step 5 – Check Which PHP Version WP-CLI Is Using
This is one of the most commonly missed checks on VPS servers.
A server can have several PHP versions installed at the same time. The website might run PHP 8.3 while the command-line wp executable launches through PHP 7.4.
Check the output of:
wp --info
You may see something similar to:
PHP binary: /usr/local/lsws/lsphp74/bin/php
PHP version: 7.4.33
That tells you which PHP interpreter is executing WP-CLI. It does not necessarily tell you which PHP version serves the website.
This distinction matters because WordPress plugins, themes, and custom code that work under PHP 8.3 may fail under PHP 7.4. A WP-CLI fatal error can therefore be caused by the wrong CLI PHP runtime rather than by the website itself.
Step 6 – Find the PHP Version Used by the Website
On an OpenLiteSpeed or CyberPanel server, virtual-host configuration is commonly stored under:
/usr/local/lsws/conf/vhosts/
From a root shell, you can search the site’s configuration for LiteSpeed PHP handlers:
grep -RniE 'lsphp[0-9]+|path.*lsphp' \
/usr/local/lsws/conf/vhosts/example.com/ \
/usr/local/lsws/conf/httpd_config.conf \
2>/dev/null
The options used here are useful to understand:
-Rsearches recursively.-nshows matching line numbers.-imakes matching case-insensitive.-Eenables extended regular expressions.2>/dev/nullhides irrelevant error output from the search.
You might find several PHP handlers because old or backup configuration files still exist:
vhost.conf0: path /usr/local/lsws/lsphp80/bin/lsphp
vhost.conf.txt: path /usr/local/lsws/lsphp82/bin/lsphp
vhost.conf: path /usr/local/lsws/lsphp83/bin/lsphp
httpd_config.conf: path lsphp74/bin/lsphp
Do not automatically use the first result. Files such as vhost.conf0, vhost.conf.old, or vhost.conf.txt may be backups or historical copies.
Identify the active virtual-host configuration. If the active vhost.conf contains:
path /usr/local/lsws/lsphp83/bin/lsphp
the website is using LiteSpeed PHP 8.3.
If you are using Apache, Nginx, PHP-FPM, or another hosting stack, the method for locating the active PHP handler will be different. The goal remains the same: determine the PHP version that actually serves the production website.
Step 7 – Run WP-CLI Through the Correct PHP Binary
If the website uses PHP 8.3, explicitly execute WP-CLI with PHP 8.3:
/usr/local/lsws/lsphp83/bin/php /usr/bin/wp --info
The output should now report the intended PHP binary and version.
Test WordPress again:
/usr/local/lsws/lsphp83/bin/php /usr/bin/wp core version
For convenience, define shell variables:
PHP=/usr/local/lsws/lsphp83/bin/php
WP=/usr/bin/wp
You can now write:
$PHP $WP core version
instead of typing both full paths every time.
Remember that these shell variables exist only in the current shell session unless you deliberately make them persistent. If you reconnect through SSH, open a new terminal, or switch users again, you may need to define them again.
If you run:
$PHP $WP post-type list
before defining $PHP and $WP, Bash may produce an error such as:
bash: post-type: command not found
That does not mean WP-CLI is missing the command. It usually means the shell variables are empty.
Step 8 – Optional: Create a Database Backup
This step is optional if you already have a recent, verified, restorable backup that covers the WordPress database before the cleanup.
If you do not already have such a backup, create one before permanently deleting thousands of records.
BACKUP="$HOME/wordpress-before-trash-cleanup-$(date +%F-%H%M%S).sql"
$PHP $WP db export "$BACKUP"
Then confirm that the backup file exists and has a reasonable size:
ls -lh "$BACKUP"
The timestamp prevents accidental overwriting and creates a filename similar to:
wordpress-before-trash-cleanup-2026-09-17-054500.sql
On WordPress Multisite, a normal database export generally contains the database used by the network, not only one site’s posts. Protect the file appropriately because it may contain data for multiple sites and users.
A backup is useful only if it can actually be restored. If your hosting provider already creates verified database snapshots or you maintain tested off-server backups, creating another local SQL export is optional rather than mandatory.
Step 9 – Confirm the Custom Post Type Slug
The WordPress admin may show a menu item labeled “Stories”, but that does not prove the registered post type is called story.
The internal slug could instead be something such as stories, user_story, bookmark, or another developer-defined name.
List the registered post types:
$PHP $WP post-type list \
--fields=name,label \
--format=table
To narrow the results:
$PHP $WP post-type list \
--fields=name,label \
--format=table | grep -i story
If the result is:
story Stories
then the human-readable label is “Stories” and the machine-readable post type slug is story.
It is the slug that must be supplied to --post_type.
Step 10 – Optional for Single-Site WordPress: Select the Multisite Site
If this is a normal single-site WordPress installation, this step is optional. You can usually omit --url from the remaining commands as long as you are already inside the correct WordPress installation.
If this is WordPress Multisite, explicitly identify the site you intend to operate on:
--url=https://example.com/
For example:
$PHP $WP post list \
--url=https://example.com/ \
--post_type=story
In Multisite, the same WordPress installation can contain many sites. The --url parameter tells WP-CLI which site context should be loaded.
This can affect the posts table, options, active plugins, registered post types, theme behavior, and site-specific configuration loaded by WordPress.
For destructive Multisite operations, specifying the site explicitly is preferable to relying on an assumption about which site WP-CLI will use.
For the rest of this tutorial, Multisite examples include:
--url=https://example.com/
If you are working on a single-site installation, you can remove that argument from the commands.
Step 11 – Count the Exact Posts Before Deleting Anything
At this point we know the correct Linux user, WordPress directory, PHP runtime, custom post type, and – when relevant – Multisite site.
Do not delete anything yet.
First count exactly how many trashed Stories match the intended query:
$PHP $WP post list \
--url=https://example.com/ \
--post_type=story \
--post_status=trash \
--format=ids | wc -w
For a single-site installation, the equivalent command can omit --url:
$PHP $WP post list \
--post_type=story \
--post_status=trash \
--format=ids | wc -w
Suppose the result is:
21071
This is a critical safety checkpoint.
How the Count Command Works
wp post list queries WordPress posts. Despite the command name, it works with custom post types too.
--post_type=story restricts the query to the story custom post type.
--post_status=trash restricts the query to records currently in Trash. This is one of the most important protections in the entire workflow.
--format=ids tells WP-CLI to output only numeric post IDs instead of a formatted table.
The pipe character sends those IDs to:
wc -w
wc -w counts whitespace-separated values. Because each post ID is one value, the result is the number of matching posts.
If WordPress admin shows roughly 21,000 trashed Stories and WP-CLI reports 21,071, the query is consistent with what you expected.
If you expected about 21,000 and the command returns 500,000, stop. If you expected 21,000 and it returns 12, stop. Investigate the mismatch before issuing a permanent deletion command.
Step 12 – Inspect the First Batch Before Deleting
Before the actual deletion, inspect a small batch of IDs:
$PHP $WP post list \
--url=https://example.com/ \
--post_type=story \
--post_status=trash \
--posts_per_page=200 \
--orderby=ID \
--order=ASC \
--format=ids
This command does not delete anything.
--posts_per_page=200 restricts the result to 200 records.
--orderby=ID orders the records by WordPress post ID.
--order=ASC processes lower IDs first.
The result should be a collection of numeric post IDs belonging to the Trash query you already verified.
For single-site WordPress, omit the --url argument.
Why You Should Not Delete 20,000 Posts in One Huge Shell Command
You may encounter examples such as:
wp post delete $(wp post list --post_status=trash --format=ids) --force
This can be convenient for a small number of posts, but it is a poor default for tens of thousands of records.
The shell must expand every returned ID into one enormous command line. Operating systems impose limits on argument length, and WordPress may also need to execute deletion hooks, remove metadata, update taxonomy relationships, invalidate caches, and run plugin-specific cleanup for every post.
If a very large operation fails partway through, it is also harder to monitor and troubleshoot.
Controlled batches reduce these risks and make progress easier to observe.
Step 13 – Permanently Delete the Trashed Posts in Batches
After confirming the target count and inspecting the first batch, you can perform the cleanup.
Set TOTAL to the count you obtained earlier:
TOTAL=21071
DELETED=0
while true; do
ids=$($PHP $WP post list \
--url=https://example.com/ \
--post_type=story \
--post_status=trash \
--posts_per_page=200 \
--orderby=ID \
--order=ASC \
--format=ids)
[ -z "$ids" ] && break
COUNT=$(wc -w <<< "$ids")
if ! $PHP $WP post delete \
--url=https://example.com/ \
--force \
--defer-term-counting \
$ids >/dev/null; then
echo "ERROR: deletion failed. Stopping."
break
fi
DELETED=$((DELETED + COUNT))
echo "Deleted $DELETED / $TOTAL trashed stories"
sleep 1
done
echo "Deletion loop finished."
For a single-site installation, remove both occurrences of:
--url=https://example.com/ \
How the Batch Deletion Script Works
TOTAL=21071
This stores the expected total number of records in a shell variable.
The value is used only for progress reporting. It does not determine which posts are selected or deleted.
DELETED=0
This initializes the progress counter.
After each successful batch, the number of deleted IDs is added to this value.
while true; do
This starts a Bash loop that continues until the script explicitly reaches a break.
The normal exit condition is reached when no matching trashed Stories remain.
Selecting the Next 200 IDs
ids=$($PHP $WP post list \
--url=https://example.com/ \
--post_type=story \
--post_status=trash \
--posts_per_page=200 \
--orderby=ID \
--order=ASC \
--format=ids)
The output of wp post list is captured in the Bash variable named ids.
After the first 200 records are permanently deleted, they no longer have the trash status and therefore no longer match the query. The next loop retrieves the next available batch.
Stopping When Nothing Remains
[ -z "$ids" ] && break
-z checks whether a string has zero length.
If $ids is empty, there are no more matching posts and the loop exits.
Counting the Current Batch
COUNT=$(wc -w <<< "$ids")
The Bash here-string operator passes the IDs into wc -w.
A normal batch may contain 200 IDs, while the last batch may contain only 71. Counting the current batch keeps the displayed progress accurate.
Permanently Deleting the Batch
$PHP $WP post delete \
--url=https://example.com/ \
--force \
--defer-term-counting \
$ids
wp post delete accepts one or more WordPress post IDs.
The contents of $ids are expanded into those IDs.
Why --force Is Necessary
Without --force, WordPress deletion commands may move eligible content into Trash rather than permanently remove it.
Our target records are already in Trash, and the purpose of this maintenance operation is permanent deletion. --force makes that intent explicit.
Why Use --defer-term-counting
Posts can be associated with categories, tags, and custom taxonomies. Deleting them can require WordPress to update taxonomy term counts.
When processing many posts together, deferring term-count recalculation until the operation has processed the batch can reduce unnecessary repeated work.
This is particularly useful when thousands of posts have taxonomy relationships.
Why Normal Success Output Goes to /dev/null
>/dev/null
Without this redirection, WP-CLI may print an individual success message for every deleted post.
For 20,000 posts, that produces thousands of lines of terminal output that provide little practical value.
Only standard output is discarded. Error output is not redirected, so important failures can still appear in the terminal.
Stopping on a Failed Batch
if ! command; then
echo "ERROR: deletion failed. Stopping."
break
fi
The ! reverses the command’s success test.
If WP-CLI returns a failure status, the loop stops instead of blindly continuing with additional batches.
A failure could indicate a PHP fatal error, plugin problem, database issue, permission problem, or server resource problem. Stopping makes the problem easier to investigate.
Displaying Progress
DELETED=$((DELETED + COUNT))
echo "Deleted $DELETED / $TOTAL trashed stories"
After every successful batch, the script updates the counter and prints progress such as:
Deleted 200 / 21071 trashed stories
Deleted 400 / 21071 trashed stories
Deleted 600 / 21071 trashed stories
This counter is useful for monitoring, but it should not be treated as the final proof of success. The database should be queried again after the cleanup.
Why Pause for One Second
sleep 1
This pauses the loop for one second between batches.
WordPress does not require the delay. It is simply a conservative production measure that prevents the script from immediately firing one batch after another without any pause.
On a lightly loaded dedicated server, the delay may be unnecessary. On a busy production VPS, a short pause can make resource usage more predictable.
Why Use WordPress Deletion Instead of Raw SQL?
A database administrator could remove rows directly from the posts table with SQL, and raw SQL can certainly be faster.
The problem is that a WordPress post is rarely represented by only one row in the posts table.
Posts can also have post metadata, taxonomy relationships, comments, plugin-specific records, cached values, and custom cleanup logic attached to WordPress deletion hooks.
Using:
wp post delete
allows the deletion to pass through WordPress rather than bypassing the application completely.
This is especially important for custom post types created or managed by plugins.
Direct SQL can be appropriate for specialized maintenance when an engineer fully understands the schema, relationships, hooks, and cleanup requirements. It should not be the default solution merely because the dataset is large.
Choosing an Appropriate Batch Size
There is no universal batch size that is correct for every WordPress site.
A batch of 200 is a conservative starting point for a production cleanup involving tens of thousands of records.
Simple posts with little metadata and few hooks may process comfortably in batches of 500 or more. A custom post type with substantial metadata, multiple taxonomy relationships, expensive deletion callbacks, remote API calls, or complex plugin logic may require smaller batches.
Do not increase the batch size simply because the VPS has plenty of CPU and RAM. The cost of deleting a WordPress post depends on application behavior as well as hardware.
Step 14 – Verify That Trash Is Actually Empty
Never treat the message:
Deletion loop finished.
as proof that every intended record was removed.
Run the original count again:
$PHP $WP post list \
--url=https://example.com/ \
--post_type=story \
--post_status=trash \
--format=ids | wc -w
For single-site WordPress, omit --url.
The expected result is:
0
This is the authoritative confirmation that no records matching the intended site, post type, and Trash status remain.
Step 15 – Confirm That Other Stories Still Exist
A successful Trash cleanup should not unexpectedly remove published, draft, pending, private, or other non-target content.
You can inspect the remaining Stories:
$PHP $WP post list \
--url=https://example.com/ \
--post_type=story \
--post_status=any \
--format=count
Again, single-site installations can omit --url.
If you want more detail, list the remaining records or inspect counts by status before and after maintenance.
Optional – Check Server Health After a Large Cleanup
After deleting thousands of records, you may want to inspect the VPS rather than immediately assuming everything is finished.
Check system load:
uptime
Check memory:
free -h
Check disk usage:
df -h
Experienced administrators may also inspect MariaDB activity, slow queries, PHP errors, web-server logs, and WordPress application logs when relevant.
Do Not Assume the Database File Will Immediately Shrink
Deleting thousands of WordPress records reduces the amount of live data stored in the database, but it does not necessarily reduce the physical database files by the same amount immediately.
InnoDB storage management, table fragmentation, free pages, and table optimization are separate database topics.
Do not automatically run aggressive OPTIMIZE TABLE operations against a production database simply because you deleted many WordPress posts. Evaluate database maintenance separately, especially on large or busy sites.
Common Mistake: Running WP-CLI Under the Wrong PHP Version
If:
wp --info
reports PHP 7.4 while the website actually runs PHP 8.3, WordPress plugins or custom code may behave differently from the live website.
Instead, execute WP-CLI through the correct PHP binary:
/usr/local/lsws/lsphp83/bin/php /usr/bin/wp
Matching the CLI runtime to the production runtime removes an important source of unexpected failures.
Common Mistake: Forgetting to Define the Shell Variables
If you enter:
$PHP $WP post-type list
before defining:
PHP=/usr/local/lsws/lsphp83/bin/php
WP=/usr/bin/wp
Bash may report:
bash: post-type: command not found
The WP-CLI command is not necessarily the problem. The variables may simply be empty in the current shell session.
Common Mistake: Using the Admin Label Instead of the CPT Slug
A WordPress admin menu labeled “Stories” does not guarantee that the internal post type is story.
Always confirm it with:
$PHP $WP post-type list \
--fields=name,label \
--format=table
Common Mistake: Omitting --post_status=trash
Compare these two queries:
$PHP $WP post list \
--post_type=story \
--post_status=trash \
--format=ids
and:
$PHP $WP post list \
--post_type=story \
--format=ids
The first explicitly targets trashed Stories.
The second is broader and should not be used as the selection side of a permanent Trash cleanup without understanding exactly what it returns.
With destructive commands, narrow filters are a safety feature.
Common Mistake: Forgetting Multisite Context
This issue applies only to WordPress Multisite.
When one WordPress installation contains multiple sites, include the intended site URL:
--url=https://example.com/
On a normal single-site installation, this parameter is generally optional.
Common Mistake: Automatically Using --skip-plugins
WP-CLI supports global options such as --skip-plugins, and they can be useful when troubleshooting a broken plugin.
Do not add --skip-plugins to a production custom-post deletion command merely because you want the operation to run faster.
The plugin that registers the custom post type may need to load. Other plugins may also register cleanup actions that are supposed to run when content is permanently deleted.
Similarly, custom post types may be registered by themes or must-use plugins.
Only bypass normal WordPress components when you understand what functionality you are disabling.
Compact Workflow for Future Cleanup Jobs
Once you understand what each command does, the production workflow can be summarized more compactly.
Find the website owner:
stat -c '%U:%G' wp-config.php
Switch to that user:
su -s /bin/bash siteuser1234
Enter WordPress:
cd /home/example.com/public_html/
Define the PHP and WP-CLI binaries:
PHP=/usr/local/lsws/lsphp83/bin/php
WP=/usr/bin/wp
Verify the environment:
$PHP $WP --info
$PHP $WP core version
Optionally create a database backup if you do not already have a recent verified backup:
BACKUP="$HOME/wordpress-before-trash-cleanup-$(date +%F-%H%M%S).sql"
$PHP $WP db export "$BACKUP"
Verify the CPT:
$PHP $WP post-type list \
--fields=name,label \
--format=table | grep -i story
On Multisite, include --url. On a single-site installation, omit it if unnecessary.
Count the target:
$PHP $WP post list \
--url=https://example.com/ \
--post_type=story \
--post_status=trash \
--format=ids | wc -w
Inspect the first batch:
$PHP $WP post list \
--url=https://example.com/ \
--post_type=story \
--post_status=trash \
--posts_per_page=200 \
--orderby=ID \
--order=ASC \
--format=ids
Then perform the controlled deletion:
TOTAL=21071
DELETED=0
while true; do
ids=$($PHP $WP post list \
--url=https://example.com/ \
--post_type=story \
--post_status=trash \
--posts_per_page=200 \
--orderby=ID \
--order=ASC \
--format=ids)
[ -z "$ids" ] && break
COUNT=$(wc -w <<< "$ids")
if ! $PHP $WP post delete \
--url=https://example.com/ \
--force \
--defer-term-counting \
$ids >/dev/null; then
echo "ERROR: deletion failed. Stopping."
break
fi
DELETED=$((DELETED + COUNT))
echo "Deleted $DELETED / $TOTAL trashed stories"
sleep 1
done
echo "Deletion loop finished."
Finally, verify that the intended Trash is empty:
$PHP $WP post list \
--url=https://example.com/ \
--post_type=story \
--post_status=trash \
--format=ids | wc -w
The desired result is:
0
Final Safety Checklist
- Confirm you are connected to the intended VPS.
- Confirm you are inside the intended WordPress installation.
- Confirm the website’s Linux user.
- Run WP-CLI as the appropriate site user.
- Confirm WP-CLI is using a PHP version compatible with the live website.
- Use a current verified backup, or optionally create one before permanent deletion.
- Confirm the exact custom post type slug.
- For Multisite, explicitly select the correct site with
--url. - For single-site WordPress, omit
--urlwhen it is unnecessary. - Explicitly restrict the query to
--post_status=trash. - Count the target records before deletion.
- Inspect a batch before running the destructive command.
- Use manageable batches instead of one enormous argument list.
- Stop on errors rather than blindly continuing.
- Query WordPress again after completion and verify that the target count is zero.
The Most Important Lesson
The most important part of this workflow is not the deletion loop. It is the sequence of verification that happens before the deletion loop.
A safe production cleanup should progressively narrow the scope from the server, to the website, to the Linux user, to the PHP runtime, to the WordPress installation, to the Multisite site when applicable, to the custom post type, to the Trash status, and finally to an exact count of the records you intend to remove.
By the time the permanent deletion command runs, there should be very little ambiguity left.
WP-CLI is powerful because it removes much of the overhead of browser-based WordPress administration. That same power means a badly targeted command can affect thousands of records very quickly.
Verify the environment, narrow the query, inspect the expected count, use manageable batches, allow WordPress to perform its normal deletion process, and independently verify the result afterward. With that discipline, WP-CLI becomes one of the most effective tools available to WordPress developers and VPS engineers for large-scale content cleanup.

