WordPress Plugin Activation, Deactivation, and Uninstall Hooks Explained

Every WordPress plugin goes through a lifecycle: it gets activated, runs on every request while active, gets deactivated, and eventually gets uninstalled. WordPress provides dedicated hooks for each of these moments — register_activation_hook(), register_deactivation_hook(), and register_uninstall_hook() — and knowing what belongs in each one is important for plugin quality. The activation hook is the right place to create database tables, set default options, flush rewrite rules, and schedule cron events. The deactivation hook should undo temporary side effects — flush rewrite rules (to remove your custom rules), clear scheduled events, and release any file locks — but should not delete user data, because deactivation is a temporary state. Uninstallation is the permanent exit: delete all tables, options, and post meta your plugin created. Confusing these three moments leads to either missing setup on activation, data accumulation that survives uninstall, or rules that linger after deactivation. This article covers all three hooks with a realistic plugin example.

Problem: Your plugin needs to create a database table and set default options on install, clean up scheduled events on deactivation, and remove all stored data when the plugin is deleted — without leaving orphaned database rows.

Solution: Use register_activation_hook() for setup, register_deactivation_hook() for reversible cleanup, and register_uninstall_hook() (or an uninstall.php file) for permanent data removal.

<?php
/**
 * Plugin Name: My Plugin
 */

// ── Activation ─────────────────────────────────────────────────────────
register_activation_hook( __FILE__, 'my_plugin_activate' );

function my_plugin_activate() {
    global $wpdb;

    // Create a custom table
    $table   = $wpdb->prefix . 'my_plugin_logs';
    $charset = $wpdb->get_charset_collate();
    $sql     = "CREATE TABLE IF NOT EXISTS $table (
        id         bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
        event_type varchar(100)        NOT NULL,
        message    text                NOT NULL,
        created_at datetime            NOT NULL DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (id)
    ) $charset;";

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta( $sql );

    // Set default options (add_option does nothing if the key already exists)
    add_option( 'my_plugin_version',  '1.0.0' );
    add_option( 'my_plugin_settings', [ 'enable_logging' => true, 'log_level' => 'error' ] );

    // Register CPT rewrite rules and flush
    // (assuming register_my_cpt() is already called on init)
    flush_rewrite_rules();

    // Schedule a daily cron event
    if ( ! wp_next_scheduled( 'my_plugin_daily_cleanup' ) ) {
        wp_schedule_event( time(), 'daily', 'my_plugin_daily_cleanup' );
    }
}

// ── Deactivation ────────────────────────────────────────────────────────
register_deactivation_hook( __FILE__, 'my_plugin_deactivate' );

function my_plugin_deactivate() {
    // Remove scheduled events
    $timestamp = wp_next_scheduled( 'my_plugin_daily_cleanup' );
    if ( $timestamp ) {
        wp_unschedule_event( $timestamp, 'my_plugin_daily_cleanup' );
    }

    // Flush rewrite rules to remove plugin's custom rules
    flush_rewrite_rules();

    // Do NOT delete options or tables here — deactivation is temporary
}

// ── Uninstall ────────────────────────────────────────────────────────────
register_uninstall_hook( __FILE__, 'my_plugin_uninstall' );

function my_plugin_uninstall() {
    global $wpdb;

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

    // Drop the custom table
    $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}my_plugin_logs" );

    // Remove any post meta added by the plugin
    $wpdb->delete( $wpdb->postmeta, [ 'meta_key' => '_my_plugin_data' ] );
}

NOTE: register_activation_hook() only works correctly when the path passed as the first argument is the main plugin file. If you call it from an included file inside a class, the hook will not fire. The safest pattern is always register_activation_hook( __FILE__, ... ) in the root plugin file. For complex plugins with separate class files, the root file can call a static method: register_activation_hook( __FILE__, [ 'My_Plugin', 'activate' ] );.