Skip to content
ExponaBLOG
WORDPRESS DEVELOPMENT & PLUGINS · DAY 092 / 100

Uninstall hooks and cleanup: a practical WordPress guide

Leave no trace when removed.

Expona Admin7 min read

Introduction

Most developers focus on activating plugins and adding features, but few think about what happens when users uninstall. Uninstall hooks and cleanup represent your last chance to leave the system in a clean state—removing database tables, options, and user data without orphaning records or breaking sites. In this practical WordPress guide, we'll cover the difference between deactivation and uninstallation, how to properly implement uninstall.php for cleanup, and the patterns that prevent support nightmares down the road.

Deactivation vs. Uninstallation: Know the Difference

Before writing a single line of cleanup code, you need to understand the distinction. Deactivation happens when a user clicks "Deactivate" on the plugin screen—the plugin stops running, but its database tables and options remain. Uninstallation happens when a user clicks "Delete" after deactivation, or when the plugin is never activated again after removal.

This matters because many plugins should preserve user data during deactivation. If someone temporarily disables a caching plugin, they likely expect those settings to return when reactivation occurs. Conversely, uninstallation is the final goodbye—the moment to perform aggressive cleanup.

WordPress gives us two paths:

  • Deactivation hook: register_deactivation_hook() fires when the plugin is deactivated but not deleted.
  • Uninstall process: Either an uninstall.php file or a registered uninstall hook that fires when the plugin is truly removed.

We'll focus on uninstallation cleanup here, but keep in mind that deactivation hooks (flushing rewrite rules, clearing transients) are often paired with uninstall handlers for a complete lifecycle.

Using uninstall.php for Cleanup

The simplest and most commonly recommended approach is creating an uninstall.php file in your plugin root directory. WordPress checks for this file when a user uninstalls a plugin, and executes it with the same capability checks it uses elsewhere.

A minimal uninstall.php looks like this:

php
<?php
// uninstall.php

// Exit if uninstall.php is not called by WordPress
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    exit;
}

// Remove options
delete_option( 'my_plugin_settings' );
delete_option( 'my_plugin_version' );

// Remove custom post types and their posts
$posts = get_posts( [
    'post_type'   => 'my_custom_type',
    'numberposts' => -1,
    'fields'      => 'ids',
] );

foreach ( $posts as $post_id ) {
    wp_delete_post( $post_id, true );
}

// Remove user meta
delete_metadata( 'user', 0, 'my_plugin_user_preference', '', true );
?>

The constant WP_UNINSTALL_PLUGIN is set by WordPress before executing uninstall.php. Always check for it—it's your guard against accidental execution if someone requests the file directly from a web browser.

Key points about uninstall.php:

  • It runs with no user logged in, but with full WordPress bootstrapped and database access available.
  • It can use all WordPress functions—delete_option(), wp_delete_post(), database queries, etc.
  • If your plugin defines a custom table, you should drop it here (more on that below).
  • WordPress automatically handles multisite nuances if you set things up correctly.

Removing Options and Custom Tables

Plugin data lives in two main places: the wp_options table for settings and serialized data, and custom database tables for structured data like logs, subscriptions, or relationships.

Removing options is straightforward:

php
<?php
// In uninstall.php
delete_option( 'my_plugin_main_settings' );
delete_option( 'my_plugin_license_key' );
delete_option( 'my_plugin_cache_version' );
?>

If you have multiple options with a common prefix, you can query and delete them in bulk:

php
<?php
global $wpdb;

// Delete all options that start with 'my_plugin_'
$wpdb->query( $wpdb->prepare(
    "DELETE FROM $wpdb->options WHERE option_name LIKE %s",
    'my_plugin_%'
) );
?>

Custom tables require explicit dropping. If your plugin creates a table during activation, you must drop it during uninstallation:

php
<?php
global $wpdb;

$table_name = $wpdb->prefix . 'my_plugin_logs';

// Drop the table
$wpdb->query( "DROP TABLE IF EXISTS $table_name" );
?>

Use IF EXISTS to avoid errors if the table never existed or was already removed. Always use $wpdb->prefix to respect WordPress's table prefix configuration—many shared hosts and security-conscious admins customize this.

A real-world scenario: imagine a plugin that stores API call logs in a custom table. If you don't drop it during uninstallation, that orphaned table remains, consuming storage and confusing future audits. Years later, developers find mysterious tables and have no idea what they're for.

Ask Before Deleting User Data

Here's where cleanup gets ethical and legally important. Not all data should be deleted. If your plugin stores user-created content—drafts, submissions, analytics records—you need to offer a choice.

The pattern is to check for a flag in the plugin's settings. If a user explicitly opts into aggressive cleanup, delete everything. Otherwise, preserve their data:

php
<?php
// In uninstall.php

if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    exit;
}

$delete_user_data = get_option( 'my_plugin_delete_user_data_on_uninstall' );

// Always remove plugin-only settings
delete_option( 'my_plugin_settings' );
delete_option( 'my_plugin_version' );

// Only delete user-generated content if explicitly requested
if ( $delete_user_data ) {
    global $wpdb;
    
    $table_name = $wpdb->prefix . 'my_plugin_submissions';
    $wpdb->query( "DROP TABLE IF EXISTS $table_name" );
    
    // Also remove associated post meta
    delete_metadata( 'post', 0, '_my_plugin_metadata', '', true );
} else {
    // Leave user data in place; just drop the plugin's configuration table
    global $wpdb;
    $config_table = $wpdb->prefix . 'my_plugin_config';
    $wpdb->query( "DROP TABLE IF EXISTS $config_table" );
}
?>

In your plugin's settings page, you'd provide a checkbox:

php
<?php
// In your plugin's settings UI
add_settings_field(
    'my_plugin_delete_user_data_on_uninstall',
    'Delete all user data on uninstall',
    function() {
        $value = get_option( 'my_plugin_delete_user_data_on_uninstall' );
        echo '<input type="checkbox" name="my_plugin_delete_user_data_on_uninstall" value="1" ' 
            . ( $value ? 'checked' : '' ) . ' />';
        echo '<p class="description">If checked, all user submissions will be permanently deleted when this plugin is uninstalled.</p>';
    }
);
?>

This respects user autonomy. Some users archive old submissions; others want a clean break. Give them control.

Handling Multisite

On multisite installs, uninstall behavior depends on where the plugin is active. If a network admin uninstalls a network-activated plugin, you typically want to clean up data across all sites. If a site admin uninstalls a single-site-activated plugin, clean up only that site's data.

Here's a pattern that handles both:

php
<?php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    exit;
}

if ( is_multisite() ) {
    // Network-activated plugin being uninstalled
    global $wpdb;
    
    $blog_ids = $wpdb->get_col( "SELECT blog_id FROM $wpdb->blogs" );
    
    foreach ( $blog_ids as $blog_id ) {
        switch_to_blog( $blog_id );
        
        delete_option( 'my_plugin_settings' );
        $table = $wpdb->prefix . 'my_plugin_data';
        $wpdb->query( "DROP TABLE IF EXISTS $table" );
        
        restore_current_blog();
    }
} else {
    // Single-site uninstallation
    delete_option( 'my_plugin_settings' );
    global $wpdb;
    $table = $wpdb->prefix . 'my_plugin_data';
    $wpdb->query( "DROP TABLE IF EXISTS $table" );
}
?>

Always restore_current_blog() after switching to ensure the global state is correct.

Common Mistakes

We've seen several patterns that lead to problems:

  • Forgetting the WP_UNINSTALL_PLUGIN check. Without it, uninstall.php can be triggered by a direct HTTP request, leaking sensitive data or causing unexpected behavior.
  • Not using IF EXISTS in DROP statements. If the table doesn't exist, the query fails silently in some configurations but errors in others. Always use IF EXISTS.
  • Deleting user data without consent. This can violate privacy regulations (GDPR, CCPA) and user expectations. Always ask first or provide a clear plugin setting.
  • Ignoring table prefixes. Hardcoding wp_ breaks for sites with custom prefixes. Use $wpdb->prefix and $wpdb->get_col() for dynamic queries.
  • Not testing on multisite. Cleanup logic that works on single-site can fail catastrophically on multisite installs. Test both.
  • Leaving orphaned postmeta and usermeta. Remember that posts and users can have associated metadata. Use the delete_metadata() function with an empty ID to clean up metadata across all posts or users.

Key Takeaways

  • Uninstall.php is your final opportunity to leave the WordPress installation clean—use the WP_UNINSTALL_PLUGIN constant to guard execution.
  • Always remove custom tables with DROP TABLE IF EXISTS, and use $wpdb->prefix to respect custom table prefix configurations.
  • Distinguish between plugin configuration (safe to delete) and user-generated content (ask permission first via a settings checkbox).
  • Deactivation and uninstallation are separate lifecycle events; preserve data during deactivation unless you have a specific reason not to.
  • On multisite, loop through all blog IDs if you're cleaning up network-wide data, and always restore the current blog after switching.
  • Test your uninstall process thoroughly—ideally on a staging environment that matches your production setup, including custom table prefixes and multisite configurations.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles