Activation, deactivation and uninstall

Set up a plugin when it is switched on, tidy up when it is switched off, and remove its data only when the user asks.

  • Intermediate
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Write activation hooks
  • Clean up on uninstall
  • Decide what belongs in activation, deactivation and uninstall
  • Make setup safe to run more than once

Before you start

A plugin's life has three events WordPress lets you react to: it is activated, it is deactivated, and it is deleted. Each has a different job. Get them wrong and you leave orphaned cron events firing for years, or worse, delete a client's bookings because they switched the plugin off for five minutes to debug something.

Three events, three jobs

EventWhenJobNever
ActivationThe user clicks ActivateCreate tables, add capabilities, set default options, schedule events, flush rewritesPrint output, assume it runs on updates
DeactivationThe user clicks DeactivateStop scheduled work, flush rewritesDelete data: deactivation is often temporary
UninstallThe user deletes the pluginRemove options, tables, capabilities and, if asked, contentRun without checking it is a real uninstall

Registering activation and deactivation

Both hooks are registered in the main file, at the top level, because they identify the plugin by __FILE__:

PHP
// wp-content/plugins/myplugin/myplugin.php
require_once MYPLUGIN_PATH . 'includes/install.php';

register_activation_hook( __FILE__, 'myplugin_activate' );
register_deactivation_hook( __FILE__, 'myplugin_deactivate' );

Two rules catch people out:

  • Do not register them inside another hook, such as plugins_loaded or init. Activation happens on a special request where the plugin file is loaded and the activation action fires straight away; a registration inside a later hook is too late.
  • The callback must exist when the hook fires. Load the file that defines it before registering, as above.

Default settings in one place

Activation needs the plugin's default settings, and so will the settings screen later. Define them once:

PHP
<?php
// wp-content/plugins/myplugin/includes/helpers.php
defined( 'ABSPATH' ) || exit;

/**
 * Default values for the myplugin_settings option.
 */
function myplugin_default_settings(): array {
    return array(
        'events_per_page' => 10,
        'currency'        => 'GBP',
        'max_guests'      => 5,
        'delete_data'     => false,
    );
}

/**
 * Current settings, with defaults for any missing keys.
 */
function myplugin_get_settings(): array {
    $saved = get_option( 'myplugin_settings', array() );

    return wp_parse_args( is_array( $saved ) ? $saved : array(), myplugin_default_settings() );
}

The activation routine

PHP
<?php
// wp-content/plugins/myplugin/includes/install.php
defined( 'ABSPATH' ) || exit;

/**
 * Runs when the plugin is activated.
 *
 * @param bool $network_wide True when network-activated on Multisite.
 */
function myplugin_activate( bool $network_wide = false ): void {
    // 1. Database tables. dbDelta() is safe to run again.
    myplugin_install_tables();

    // 2. Capabilities, added once and stored with the role.
    $admin = get_role( 'administrator' );

    if ( $admin ) {
        $admin->add_cap( 'myplugin_manage_bookings' );
    }

    // 3. Default settings, without overwriting existing ones.
    add_option( 'myplugin_settings', myplugin_default_settings() );

    // 4. Scheduled work, without creating duplicates.
    if ( ! wp_next_scheduled( 'myplugin_daily_cleanup' ) ) {
        wp_schedule_event( time(), 'daily', 'myplugin_daily_cleanup' );
    }

    // 5. Rewrite rules for the events post type.
    myplugin_register_post_types();
    flush_rewrite_rules();
}

myplugin_install_tables() is the dbDelta() function from the WordPress course's $wpdb lesson. The custom tables lesson in this course improves it.

Every step is idempotent: running it twice has the same effect as running it once. That matters because users deactivate and reactivate plugins all the time.

  • add_option() does nothing if the option exists, so reactivating never resets a user's settings. update_option() here would.
  • wp_next_scheduled() stops a second copy of the daily event.
  • add_cap() and dbDelta() are safe to repeat.

The rewrite flush works as described in the WordPress course: on the activation request your init callback has not run yet, so the routine registers the post type itself before flushing.

Stopping activation cleanly

If the plugin cannot work, for example because a PHP extension is missing, say so and stop:

PHP
// wp-content/plugins/myplugin/includes/install.php, at the start of myplugin_activate().
if ( ! extension_loaded( 'intl' ) ) {
    wp_die(
        esc_html__( 'My Plugin needs the PHP intl extension. Ask your host to enable it, then activate the plugin again.', 'myplugin' ),
        esc_html__( 'Plugin not activated', 'myplugin' ),
        array( 'back_link' => true )
    );
}

wp_die() during activation stops the request before WordPress marks the plugin active. PHP and WordPress version checks are already handled by the Requires PHP and Requires at least headers, so you only need this for other requirements.

Never echo during activation. Output on the activation request triggers the "generated unexpected output" warning and can break the redirect back to the Plugins screen. If you want to welcome the user, set an option during activation and show an admin notice on the next request. The admin notices lesson shows how.

Activation does not run on updates

When a user updates the plugin, from the dashboard, with WP-CLI or by deploying new files, the activation hook does not run. Anything that must happen after an update, such as adding a column to the bookings table, needs a version check that runs on every load. You will build one in the custom tables lesson, and the data migrations lesson extends it. Design activation so the same functions can be called from that upgrade path.

Deactivation

PHP
<?php
// wp-content/plugins/myplugin/includes/install.php

/**
 * Runs when the plugin is deactivated. Keeps all data.
 */
function myplugin_deactivate(): void {
    wp_clear_scheduled_hook( 'myplugin_daily_cleanup' );

    unregister_post_type( 'myplugin_event' );
    flush_rewrite_rules();
}

Stop anything that would keep running without your code: cron events would otherwise fire hooks that nobody listens to. Flush rewrites so event URLs return a clean 404 instead of a broken template.

Leave tables, options, capabilities and content alone. A user who deactivates to test a conflict expects everything back when they reactivate.

Uninstall

WordPress offers two ways to run code when a plugin is deleted: an uninstall.php file in the plugin's root folder, or register_uninstall_hook(). Use the file. It is easier to find, it does not need the rest of the plugin to load, and if both exist WordPress uses the file.

PHP
<?php
// wp-content/plugins/myplugin/uninstall.php

// Only run when WordPress is uninstalling this plugin.
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;

$myplugin_settings = get_option( 'myplugin_settings', array() );

// Always remove things that are useless without the plugin.
delete_option( 'myplugin_db_version' );
wp_clear_scheduled_hook( 'myplugin_daily_cleanup' );

$myplugin_admin = get_role( 'administrator' );

if ( $myplugin_admin ) {
    $myplugin_admin->remove_cap( 'myplugin_manage_bookings' );
}

// Content and records only go if the user opted in.
if ( empty( $myplugin_settings['delete_data'] ) ) {
    return;
}

global $wpdb;

$wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . 'myplugin_bookings' ) );

$myplugin_event_ids = $wpdb->get_col(
    $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s", 'myplugin_event' )
);

foreach ( $myplugin_event_ids as $myplugin_event_id ) {
    wp_delete_post( (int) $myplugin_event_id, true );
}

delete_metadata( 'user', 0, 'myplugin_favourites', '', true );
delete_metadata( 'user', 0, 'myplugin_dismissed_notices', '', true );
delete_option( 'myplugin_settings' );

Points to notice:

  • WP_UNINSTALL_PLUGIN is only defined when WordPress runs the file as part of deleting the plugin. The check stops anyone running it by URL.
  • uninstall.php runs in the global scope, so its variables are global. Prefix them like everything else.
  • The plugin is not active during uninstall. Your post type and taxonomies are not registered, so the code finds events with a direct query by post_type and then lets wp_delete_post() remove each post with its meta and term relationships.
  • delete_metadata() with $delete_all set to true removes a meta key from every user in one call.
  • The settings option is deleted last, because the code reads delete_data from it first.

Ask before deleting content

Whether to delete content on uninstall is a product decision, and the safe default is no. Users delete and reinstall plugins to fix problems, and site owners do not expect "Delete" to wipe two years of bookings. The delete_data setting, which you add to the settings screen in the Settings API lesson, puts the choice in the user's hands. Settings, caches and capabilities can always go, because they are useless without the plugin.

On a site with tens of thousands of events, deleting every post in one uninstall request can hit PHP's time limit. Large plugins delete in batches, or tell users to export first. On Multisite, uninstall.php runs once, for the main site; the Multisite lesson shows how to clean every site.

Summary

  • Register activation and deactivation hooks in the main file, at the top level, with __FILE__.
  • Activation creates tables, capabilities, default options and scheduled events, and flushes rewrites; make every step safe to repeat.
  • Activation never runs on updates; build an upgrade check that reuses the same functions.
  • Deactivation stops scheduled work and flushes rewrites, but keeps all data.
  • uninstall.php checks WP_UNINSTALL_PLUGIN, removes options, capabilities and events, and deletes content only when the user opted in.

Further reading