← Back to blog

How to test that your WordPress plugin actually uninstalls itself

The SandyWP team 8 min read

Take a snapshot of a clean WordPress install, install and use your plugin, delete it from the Plugins screen, then diff the options, tables, cron events, roles and uploads against the snapshot. Anything that survives is a bug in your uninstall routine.

Most developers test this by deactivating the plugin and looking at the database, which tests nothing: deactivation and uninstall are different code paths, and only one of them is supposed to delete data.

Deactivate, uninstall and delete are three separate things

They get used interchangeably in conversation and they do not mean the same thing to WordPress.

Action Runs deactivation hook Runs uninstall code Removes files
Deactivate in wp-admin Yes No No
Delete in wp-admin Yes (deactivate first) Yes Yes
wp plugin uninstall Only with --deactivate Yes Yes, unless --skip-delete
wp plugin delete No No Yes

That last row is the one that wastes an afternoon. WP-CLI documents wp plugin delete as "Deletes plugin files without deactivating or uninstalling". The files vanish, your uninstall code never runs, and the leftover rows in wp_options look like your cleanup failed when it was never invoked.

Use wp plugin uninstall <slug> --deactivate when you want the real path from the command line, or --skip-delete when you want the uninstall routine to run but the files to stay put so you can run it again.

Four reasons an uninstall routine silently does nothing

None of these throw an error. The plugin disappears, the site keeps working, and the data stays.

1. uninstall.php wins, always

If a uninstall.php file exists in the plugin's base folder, WordPress calls it and never fires the hook. The reference for register_uninstall_hook() is explicit that the hook does not execute when the plugin ships that file, because WordPress "bypasses the hook entirely" and calls the file instead.

Plugins accumulate both. Someone adds uninstall.php for one table in 2023, someone else adds a register_uninstall_hook() call in 2025, and from that day the second one has never run.

2. The hook callback is frozen in the database at activation time

register_uninstall_hook() does not register anything at runtime. It writes the callback name into the uninstall_plugins option, and that stored string is what WordPress calls later.

So if you rename the function, move it into a class, or change the namespace, sites that activated the old version still have the old callback stored. It resolves to nothing and the uninstall does nothing.

The same storage requirement explains two rules from the handbook: you "cannot pass an anonymous function to register_uninstall_hook", and only a static class method or a plain function will work. An instance method cannot be serialized into an option.

Check what is actually stored before you trust your own code:

wp option get uninstall_plugins --format=json

3. uninstall.php runs with your plugin unloaded

The main plugin file is not included. Your autoloader is not registered, your constants are not defined, your helper functions do not exist.

A uninstall.php that calls MyPlugin\Cleanup::run() or uses MYPLUGIN_TABLE fatals on a file that most people never look at. Write it as plain procedural code against $wpdb, delete_option() and wp_clear_scheduled_hook(), and require anything it needs by hand.

There is a matching trap in the other direction. The handbook says to always check for WP_UNINSTALL_PLUGIN in uninstall.php to protect against direct access, and that the constant is "NOT defined when uninstall is performed by register_uninstall_hook()". Copy that guard into your hook callback and the callback returns immediately, every time.

4. You never used the plugin before deleting it

An uninstall routine can only delete rows that exist. Install, activate, delete: nothing is left behind because nothing was written.

The test has to include the messy middle. Save the settings, run the import, let the cron job fire once, upload a file. Then delete.

The test: snapshot, use, delete, diff

Uninstall is destructive and one-shot, which is why this belongs on a disposable site rather than your working install. Create a clean sandbox, take the snapshot over SSH, and throw the whole thing away afterwards.

Before you install the plugin:

wp option list --search='*' --fields=option_name | sort > /tmp/before-options.txt
wp db tables --all-tables > /tmp/before-tables.txt
wp cron event list --fields=hook --format=csv | sort > /tmp/before-cron.txt
wp option get wp_user_roles --format=json > /tmp/before-roles.json
find wp-content/uploads -type d | sort > /tmp/before-uploads.txt

Now install the plugin, activate it, and use it properly. Then delete it from the Plugins screen, or over the command line:

wp plugin uninstall my-plugin --deactivate

Then take the same five measurements into after-* files and compare:

diff /tmp/before-options.txt /tmp/after-options.txt
diff /tmp/before-tables.txt /tmp/after-tables.txt
diff /tmp/before-cron.txt /tmp/after-cron.txt
diff /tmp/before-roles.json /tmp/after-roles.json
diff /tmp/before-uploads.txt /tmp/after-uploads.txt

Every line the diff reports is something your plugin created and did not remove. That is the whole test, and it takes about four minutes once the site exists.

Turn on debug mode before the delete. A fatal in uninstall.php produces a white screen on an admin request that people click past, and the stack trace in debug.log names the missing class immediately.

The five things uninstall routines usually miss

The diff will find these for you, but they are worth knowing in advance because they are the recurring ones.

Scheduled events. Deactivation hooks usually call wp_clear_scheduled_hook(). Uninstall routines usually assume deactivation already ran, which is true in wp-admin and not true for every path. Clear them in both.

Custom capabilities. add_cap() writes into the wp_user_roles option, not into code. Those capabilities survive the plugin by default and stay attached to roles forever, which is why the roles diff above is a separate line.

Custom tables. $wpdb->prefix is not wp_, and it is not the same on multisite. Build the name at uninstall time; never hardcode it.

Transients. With no persistent object cache, transients are rows in wp_options named _transient_yourprefix_* and _transient_timeout_yourprefix_*. Deleting the option you named and forgetting the timeout row leaves half of each pair behind.

Post meta and user meta. Deleting your custom post type's posts with wp_delete_post( $id, true ) cleans their meta. Meta you attached to other people's posts, or to users, has to be deleted explicitly.

Multisite is a second test, not the same one

uninstall.php runs once for the network, not once per site. Whatever it deletes, it deletes in the context of whichever site the request happened on.

The handbook's own warning is about the fix rather than the problem: looping every blog to delete options is "very resource intensive" on a large network. That is a real constraint, and the honest answer is usually to batch the cleanup rather than to skip it.

Test it on an actual network. Tick Multisite when you create a sandbox, add three or four subsites, and the diff will show you within a minute whether site 2 and site 3 kept their options, which is invisible on a single-site install. The rest of that routine is in how to test a WordPress plugin on multisite.

Test the reinstall, not just the uninstall

The other half of the job is what happens when someone deletes the plugin and installs it again a week later.

If the uninstall was partial, activation now runs against half-populated state: the options are gone but the custom table still exists with its old schema, or the capability is still attached to a role that your activation code assumes it must create. Version-upgrade routines that key off a stored version number are the usual casualty, because the version option was deleted and the data it described was not.

Run install, use, delete, install again on the same sandbox. Then throw it away and do the clean install once more to confirm both paths land in the same place.

Automate the whole thing once

The measurement is deterministic, which makes it a good candidate for a script that runs against a fresh site per build rather than a checklist someone remembers to follow.

If the plugin lives in a repository, a pull request preview that creates a real WordPress install per branch gives you somewhere to run it. The CLI does the same from a terminal or a CI job: create a site, sandywp ssh into it, run the snapshot and diff, and delete the site.

A blueprint is useful here because the test only means something against a known starting state. If the site already has six other plugins on it, the diff is noise.

What this test cannot tell you

A clean sandbox is the right place to find leftover data. It is the wrong place to find out what deleting your plugin does to a site that has been running for four years.

It will not reproduce a persistent object cache, where a deleted option can still answer from Redis until the cache is flushed, and where transients are not rows in wp_options at all.

It will not reproduce other plugins writing into your tables, or your post types, or your meta keys. On real sites they do, and deleting those rows is a decision rather than a cleanup.

It will not tell you how long the cleanup takes against a million rows. If your uninstall runs an unbatched DELETE on a large table, the place that shows up is a production host with a query timeout, not a sandbox with an empty database.

And it deliberately does not answer whether you should delete the data. Many plugins keep it on purpose so that a reinstall restores the user's settings, which is a defensible choice as long as it is a choice. The failure worth catching is the plugin that intends to clean up and quietly does not.

The short version

Deactivating tells you nothing. wp plugin delete tells you less than nothing, because it skips the code you are trying to test.

Snapshot a clean site, use the plugin properly, delete it through a path that actually runs the uninstall, and diff. If you build plugins for a living, SandyWP exists to make the clean site part take fifteen seconds rather than an afternoon.