WP-CLI Delete Commands: Which Tables Each One Touches and What It Leaves Behind

Contents
Database housekeeping in WordPress is a handful of commands that all look alike and behave nothing alike. One goes through the application and cleans up after itself; the next one touches a single table and leaves four others holding rows that point at nothing.
What makes the difference matter is that WP-CLI, with a few exceptions, has no dry run. There is no way to ask a delete command what it would do. There is only the discipline of asking a different command first.

The Two-Step Pattern That Replaces the Dry Run
Every destructive command has a harmless sibling that takes the same filter. wp post delete has wp post list; wp comment delete has wp comment list. Running the list first with --format=count answers the only question a dry run would have answered: how many rows is this about to remove.
# 0. a backup, outside the document root
wp db export /home/pi/backup-$(date +%F).sql
# 1. count, with exactly the filter that will be used
wp post list --post_type=revision --format=count
# 2. delete, with exactly the same filter
wp post list --post_type=revision --format=ids | xargs -n 100 wp post delete --force
The xargs in the last line is not decoration. Substituting several thousand IDs directly into the command line runs into the argument length limit, and the failure arrives after some of the work is done rather than before any of it – which is the one situation the whole exercise was meant to avoid.
The backup line has a detail worth keeping: without a path, wp db export writes the dump into the current directory, and the current directory during a WordPress command is usually the document root. A full database dump with a guessable name, served over HTTP, is a worse outcome than anything the cleanup was going to fix.
Two Ways to Remove the Same Row
wp post delete calls the same function the editor calls. It removes the post meta, the comments, the comment meta and the term relationships, and it fires the hooks that let plugins remove whatever they attached elsewhere – a search index entry, a cache, a row in their own table.
A direct DELETE FROM wp_posts removes one row. Everything that referred to it stays, unreachable and invisible: no query joins to a post that is gone, so the rows simply never appear again. That is tolerable until an ID is reused, and post IDs are reused after a database import – at which point the new post silently inherits the old one’s custom fields and comments.
What Each Command Actually Touches
| Command | Removes | Leaves behind |
|---|---|---|
wp post delete <id> --force |
post, meta, comments, term links | the attached media files and their attachment rows |
wp post delete <attachment> --force |
the row and the files on disk, all sizes | references to the URL inside other posts |
wp post delete <revisions> |
the revisions and their own meta rows | the parent post, untouched |
wp comment delete --status=spam |
comments and comment meta | the per-post comment counts, until a recount |
wp transient delete --expired |
only transients whose expiry has passed | every transient stored without an expiry |
wp db optimize |
nothing | a table lock for the duration of the rebuild |
The first row is the one that surprises people. Deleting a post does not delete the images in it – attachments are posts in their own right, with their own IDs, and they survive their parent. A site cleaned of a thousand old posts therefore still carries a thousand posts’ worth of media, which is where the disk space actually went.
The comment count row is the cheapest to fix and the easiest to forget: after any bulk comment deletion, wp comment recount puts the numbers back in agreement with the rows. Nothing breaks without it; the counts are simply wrong, everywhere, quietly.
The Trash Is Not a Backup
Without --force, deleting moves a post to the trash, which is a status change and nothing more. The row stays, the meta stays, the media stays, and the space is not freed – a trash emptied once a month is a housekeeping habit, not a safety net.
With --force the post skips the trash entirely and there is no undo inside WordPress at all. The only thing standing between a mistyped filter and a lost month is the dump written in step zero, which is the reason it is step zero.
Expired Is Not the Same as All
Transients are a cache with an expiry stored alongside the value. wp transient delete --expired removes those whose time has passed and is safe by construction: anything it deletes was already invalid.
--all is a different command wearing the same name. It also removes transients stored without an expiry, and some plugins use exactly those as their only storage for something that was expensive to compute – a licence check, an inventory, a rendered menu. Nothing breaks visibly; the site simply spends the next hour rebuilding things nobody asked it to rebuild, and one or two plugins behave as if they had just been installed.
Optimising Is a Rebuild, Not a Tidy-Up
wp db optimize runs OPTIMIZE TABLE, and for InnoDB that is not an optimisation but a full rebuild of the table plus its indexes. The table is locked for writes while it happens, which on a small blog is a second and on a large wp_postmeta is long enough for visitors to notice.
It is also the step that most reliably reclaims nothing. Space freed by deleted rows is already reused by InnoDB for new rows; the file on disk only shrinks if a great deal was deleted and nothing has been written since. Worth running once after a large cleanup, at a quiet hour, and not worth putting in a nightly cron – which is precisely where it usually ends up.
Questions and answers
How can rows be found that no longer point at any post after a direct DELETE?
With a join that asks explicitly for absence. For post meta, the counting step looks like this:
wp db query 'SELECT COUNT(*) FROM wp_postmeta pm LEFT JOIN wp_posts p ON p.ID = pm.post_id WHERE p.ID IS NULL'
The same pattern checks wp_comments via comment_post_ID and wp_term_relationships via object_id. The last table needs care, because object_id does not only point at posts: the link categories of the old link manager refer to wp_links, and some plugins attach terms to users or to objects of their own. There the check has to be limited to the taxonomies that are actually attached to posts.
The deletion comes only after the backup and with exactly the same condition, as DELETE pm FROM wp_postmeta pm LEFT JOIN wp_posts p ON p.ID = pm.post_id WHERE p.ID IS NULL. That is the two-step pattern from the article, only in SQL. Both statements read all of wp_postmeta, so on a large installation they are better run at a quiet hour. The wp_ prefix is the default and may differ on a given installation.
Can the number of revisions be capped permanently instead of deleting them again and again?
Yes. Set WP_POST_REVISIONS in wp-config.php: a number specifies how many revisions to keep per post, while false disables new revisions. A numeric cap is applied when a post is next saved with changes; WordPress then removes that post’s oldest revisions above the cap. Setting the constant does not immediately clean up existing revisions. With false, they are left untouched entirely, so the one-off cleanup from this article is still needed for that stock.
What does a persistent object cache change about wp transient delete?
It moves the transients out of the database and into the cache. With a persistent object cache such as Redis or Memcached, new transients no longer end up in wp_options, and wp transient delete only reaches the rows that remain there, usually leftovers from before the cache was installed. WP-CLI points this out with a warning.
Inside the cache, transients with an expiry lapse on their own, because the expiry becomes the entry’s expiration time. Usually the only way to remove all of them at once is wp cache flush, which empties the entire object cache, not just the transients. The effect is the same as with --all, only wider: nothing breaks, but the site spends a while rebuilding everything, and if several installations share the same cache, their entries may be hit too, depending on the setup.
What is different on a multisite installation?
Every site in the network has its own tables with their own prefix, such as wp_2_posts, and a WP-CLI command without --url only works on the main site. Covering the whole network therefore means running every step once per site, the count included, for instance with wp site list --field=url feeding xargs. wp db export, by contrast, backs up the whole database without further options, and with it every site at once.