• Skip to secondary menu
  • Skip to main content
  • Skip to primary sidebar
  • Home
  • Projects
  • Products
  • Themes
  • Tools
  • Request for Quote

Vengala Vinay

Having 9+ Years of Experience in Software Development

  • Home
  • WordPress
  • PHP
    • Codeigniter
  • Django
  • Magento
  • Selenium
  • Server
Home » How to Migrate Users Between Sites in WordPress Multisite (One-Time Task)

How to Migrate Users Between Sites in WordPress Multisite (One-Time Task)

When working with WordPress Multisite, a common point of confusion is user migration—especially after cloning a site using tools like NS Cloner.The key thing to understand is this:

In WordPress Multisite, users are global.
They live in the common wp_users table and are not copied per site.
What is site-specific is a user’s role and access to each blog.

So if your cloned site appears to be “missing users,” they usually already exist—you just need to assign them to the new site.

This post explains how to do that safely as a one-time migration.

What This Solution Does

  • Attaches all users from Site A to Site B
  • Preserves each user’s role wherever possible
  • Runs only once
  • Designed specifically for WordPress Multisite
  • No database exports or imports needed

Step 1: Create a MU Plugin

  1. Go to your WordPress installation:
    wp-content/
  2. Create a folder called:
    mu-plugins or plugins
  3. inside mu-plugins, create a file:
    clone-users-from-site-a-to-b.php

Step 2: Add This Code

<?php
/**
 * Plugin Name: Multisite User Migrator
 * Description: One-time (or repeated) user migration between sites in a WordPress Multisite network via Network Admin UI.
 * Author: Vinay Vengala
 * Version: 1.0
 * Network: true
 */

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

class MS_User_Migrator {

    public function __construct() {
        // Only load in multisite & for network
        if ( is_multisite() ) {
            add_action( 'network_admin_menu', array( $this, 'add_network_page' ) );
        }
    }

    /**
     * Add a page under Network Admin -> Users (or Settings).
     */
    public function add_network_page() {
        add_menu_page(
            __( 'User Migrator', 'ms-user-migrator' ),
            __( 'User Migrator', 'ms-user-migrator' ),
            'manage_network_users',
            'ms-user-migrator',
            array( $this, 'render_page' )
        );
    }

    /**
     * Render options panel + handle form submission.
     */
    public function render_page() {
        if ( ! current_user_can( 'manage_network_users' ) ) {
            wp_die( __( 'You do not have permission to access this page.', 'ms-user-migrator' ) );
        }

        $message = '';
        $error   = '';

        // Handle form submit
        if ( isset( $_POST['msum_submit'] ) ) {
            check_admin_referer( 'msum_migrate_users', 'msum_nonce' );

            $from_blog_id  = isset( $_POST['from_blog_id'] ) ? absint( $_POST['from_blog_id'] ) : 0;
            $to_blog_id    = isset( $_POST['to_blog_id'] ) ? absint( $_POST['to_blog_id'] ) : 0;
            $fallback_role = isset( $_POST['fallback_role'] ) ? sanitize_text_field( $_POST['fallback_role'] ) : 'subscriber';

            if ( ! $from_blog_id || ! $to_blog_id ) {
                $error = 'Both "From Site ID" and "To Site ID" are required.';
            } elseif ( $from_blog_id === $to_blog_id ) {
                $error = 'Source and target site IDs must be different.';
            } else {
                list( $migrated_count, $skipped_count, $err_msg ) = $this->migrate_users( $from_blog_id, $to_blog_id, $fallback_role );

                if ( $err_msg ) {
                    $error = $err_msg;
                } else {
                    $message = sprintf(
                        'Migration complete. Users added to target site: %d. Users already had access and were skipped: %d.',
                        $migrated_count,
                        $skipped_count
                    );
                }
            }
        }

        // Get last used values (to keep form filled after submit)
        $from_blog_val  = isset( $_POST['from_blog_id'] ) ? absint( $_POST['from_blog_id'] ) : '';
        $to_blog_val    = isset( $_POST['to_blog_id'] ) ? absint( $_POST['to_blog_id'] ) : '';
        $fallback_val   = isset( $_POST['fallback_role'] ) ? sanitize_text_field( $_POST['fallback_role'] ) : 'subscriber';

        ?>
        <div class="wrap">
            <h1><?php esc_html_e( 'Multisite User Migrator', 'ms-user-migrator' ); ?></h1>

            <p>Use this tool to assign users from one site (blog) in the network to another. Users already exist globally; this just attaches them to the target site with a role.</p>
            <p><strong>Note:</strong> This action cannot be easily undone. Always test on staging first.</p>

            <?php if ( $message ) : ?>
                <div id="message" class="updated notice is-dismissible"><p><?php echo esc_html( $message ); ?></p></div>
            <?php endif; ?>

            <?php if ( $error ) : ?>
                <div class="error notice is-dismissible"><p><?php echo esc_html( $error ); ?></p></div>
            <?php endif; ?>

            <form method="post">
                <?php wp_nonce_field( 'msum_migrate_users', 'msum_nonce' ); ?>

                <table class="form-table" role="presentation">
                    <tr>
                        <th scope="row"><label for="from_blog_id">From Site ID</label></th>
                        <td>
                            <input name="from_blog_id" type="number" id="from_blog_id" value="<?php echo esc_attr( $from_blog_val ); ?>" class="regular-text" required />
                            <p class="description">Source site (blog) ID. You can see it in Network Admin → Sites (hover the site name and check <code>id=</code> in the URL).</p>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="to_blog_id">To Site ID</label></th>
                        <td>
                            <input name="to_blog_id" type="number" id="to_blog_id" value="<?php echo esc_attr( $to_blog_val ); ?>" class="regular-text" required />
                            <p class="description">Target site (blog) ID where users should be added.</p>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="fallback_role">Fallback Role</label></th>
                        <td>
                            <input name="fallback_role" type="text" id="fallback_role" value="<?php echo esc_attr( $fallback_val ); ?>" class="regular-text" />
                            <p class="description">Role used if a user has no role on the source site. Default: <code>subscriber</code>. Example values: <code>administrator</code>, <code>editor</code>, <code>author</code>, <code>contributor</code>, <code>subscriber</code>.</p>
                        </td>
                    </tr>
                </table>

                <p class="submit">
                    <input type="submit" name="msum_submit" id="msum_submit" class="button button-primary" value="Run Migration">
                </p>
            </form>
        </div>
        <?php
    }

    /**
     * Migrate users from one blog to another.
     *
     * @return array [migrated_count, skipped_count, error_message]
     */
    protected function migrate_users( $from_blog_id, $to_blog_id, $fallback_role = 'subscriber' ) {
        global $wpdb;

        // Check if both blogs exist
        $from_blog = get_blog_details( $from_blog_id );
        $to_blog   = get_blog_details( $to_blog_id );

        if ( ! $from_blog || ! $to_blog ) {
            return array( 0, 0, 'One or both site IDs do not exist in this network.' );
        }

        // Get users from source blog
        $user_ids = get_users( array(
            'blog_id' => $from_blog_id,
            'fields'  => 'ID',
        ) );

        if ( empty( $user_ids ) ) {
            return array( 0, 0, 'No users found on the source site.' );
        }

        $from_prefix = $wpdb->get_blog_prefix( $from_blog_id );
        $to_prefix   = $wpdb->get_blog_prefix( $to_blog_id );

        $from_cap_key = $from_prefix . 'capabilities';
        $to_cap_key   = $to_prefix . 'capabilities';

        $migrated_count = 0;
        $skipped_count  = 0;

        foreach ( $user_ids as $user_id ) {

            // If user already has caps on target blog, skip
            $existing_caps = get_user_meta( $user_id, $to_cap_key, true );
            if ( is_array( $existing_caps ) && ! empty( $existing_caps ) ) {
                $skipped_count++;
                continue;
            }

            // Determine role from source site
            $caps = get_user_meta( $user_id, $from_cap_key, true );
            $role = $fallback_role;

            if ( is_array( $caps ) && ! empty( $caps ) ) {
                $roles = array_keys( $caps );
                if ( ! empty( $roles[0] ) ) {
                    $role = $roles[0];
                }
            }

            // Attach user to target blog
            add_user_to_blog( $to_blog_id, $user_id, $role );
            $migrated_count++;
        }

        return array( $migrated_count, $skipped_count, '' );
    }
}

new MS_User_Migrator();

 

3.How to use it

  1. Network Activate the plugin
    • Go to Network Admin → Plugins
    • Activate Multisite User Migrator

  2. Open the tool
    • Go to Network Admin → User Migrator (menu on left)
  3. Fill in the form:
    • From Site ID: source blog ID (where users currently have roles)
    • To Site ID: target blog ID (cloned site)
    • Fallback Role: e.g. subscriber (used if no role is found on source)
  4. Click Run Migration

 

 

Let us go through this plugin line by line and concept by concept so it is clear what every part is doing and how a user (or another dev) should think about it.

I will keep the explanation grouped by logical blocks instead of literally every single line, so it is understandable.

1. Plugin header and basic safety

<?php
/**
 * Plugin Name: Multisite User Migrator
 * Description: One-time (or repeated) user migration between sites in a WordPress Multisite network via Network Admin UI.
 * Author: Vinay Vengala
 * Version: 1.0
 * Network: true
 */

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

What this does

  • /** ... */ block: This is the standard WordPress plugin header.
    • Plugin Name: The name shown in the Plugins screen.
    • Description: Short description for admins.
    • Author: Your name or company.
    • Version: Plugin version.
    • Network: true: Marks this plugin as a network plugin (for Multisite). It should be network-activated from Network Admin → Plugins.
  • if ( ! defined( 'ABSPATH' ) ) exit;:
    • Security guard.
    • Ensures the file is only loaded via WordPress (which defines ABSPATH).
    • Prevents direct access via URL (e.g., some-domain.com/wp-content/plugins/...).

2. Defining the main class

class MS_User_Migrator {

    public function __construct() {
        // Only load in multisite & for network
        if ( is_multisite() ) {
            add_action( 'network_admin_menu', array( $this, 'add_network_page' ) );
        }
    }

What this does

  • class MS_User_Migrator { ... }:
    • Wraps all plugin logic inside a PHP class named MS_User_Migrator to avoid polluting the global namespace.
  • __construct():
    • Runs automatically when new MS_User_Migrator(); is called at the bottom of the file.
    • Checks is_multisite() to ensure this only runs on a WordPress Multisite install.
    • Hooks into network_admin_menu:
      • add_action( 'network_admin_menu', [ $this, 'add_network_page' ] ); means:
        • When WordPress is building the Network Admin menu, call $this->add_network_page() to add our custom page.

So: as soon as the plugin is loaded on a multisite, it registers a custom page in Network Admin.

3. Adding the Network Admin menu page

/**
 * Add a page under Network Admin -> Users (or Settings).
 */
public function add_network_page() {
    add_menu_page(
        __( 'User Migrator', 'ms-user-migrator' ),
        __( 'User Migrator', 'ms-user-migrator' ),
        'manage_network_users',
        'ms-user-migrator',
        array( $this, 'render_page' )
    );
}

What this does

  • add_menu_page() adds a new top-level menu item in Network Admin.

Parameters:

  1. Page title: __( 'User Migrator', 'ms-user-migrator' )
    • Title shown in the browser tab and at the top of the screen.
  2. Menu title: same as above – “User Migrator”.
  3. Capability: 'manage_network_users'
    • Only users with this capability (usually network admins) can see and use this page.
  4. Menu slug: 'ms-user-migrator'
    • Used in the URL: .../wp-admin/network/admin.php?page=ms-user-migrator
  5. Callback: array( $this, 'render_page' )
    • When the page is opened, WordPress calls $this->render_page() to output the HTML.

So: this is what creates the “User Migrator” item in Network Admin and links it to your form.

4. Rendering the page and handling form submit

/**
 * Render options panel + handle form submission.
 */
public function render_page() {
    if ( ! current_user_can( 'manage_network_users' ) ) {
        wp_die( __( 'You do not have permission to access this page.', 'ms-user-migrator' ) );
    }

    $message = '';
    $error   = '';

Permissions

  • First line inside render_page():
    • Checks if the current user has the capability manage_network_users.
    • If not, wp_die() stops execution and shows an error.
    • This is a safety check in addition to the menu capability.

Variables for feedback

  • $message and $error:
    • Used to store success or error messages for display in the admin UI after form submission.

4.1 Handling form submission

// Handle form submit
if ( isset( $_POST['msum_submit'] ) ) {
    check_admin_referer( 'msum_migrate_users', 'msum_nonce' );

    $from_blog_id  = isset( $_POST['from_blog_id'] ) ? absint( $_POST['from_blog_id'] ) : 0;
    $to_blog_id    = isset( $_POST['to_blog_id'] ) ? absint( $_POST['to_blog_id'] ) : 0;
    $fallback_role = isset( $_POST['fallback_role'] ) ? sanitize_text_field( $_POST['fallback_role'] ) : 'subscriber';
  • if ( isset( $_POST['msum_submit'] ) ):
    • Checks if the form was submitted (the submit button named msum_submit is set).
  • check_admin_referer( 'msum_migrate_users', 'msum_nonce' ):
    • Verifies the nonce field for security.
    • Ensures the request came from your site and is not CSRF.
  • Grabbing form values:
    • $from_blog_id: Read $_POST['from_blog_id'] and convert to integer with absint(). If not set, use 0.
    • $to_blog_id: Same for the target site.
    • $fallback_role: Read and sanitize as plain text. If not set, default to 'subscriber'.

4.2 Validating the user input

    if ( ! $from_blog_id || ! $to_blog_id ) {
        $error = 'Both "From Site ID" and "To Site ID" are required.';
    } elseif ( $from_blog_id === $to_blog_id ) {
        $error = 'Source and target site IDs must be different.';
    } else {
        list( $migrated_count, $skipped_count, $err_msg ) = $this->migrate_users( $from_blog_id, $to_blog_id, $fallback_role );

        if ( $err_msg ) {
            $error = $err_msg;
        } else {
            $message = sprintf(
                'Migration complete. Users added to target site: %d. Users already had access and were skipped: %d.',
                $migrated_count,
                $skipped_count
            );
        }
    }
}
  • Validation rules:
    • If either site ID is empty/zero → set error.
    • If both IDs are the same → also error.
  • If validation passes:
    • Calls $this->migrate_users( $from_blog_id, $to_blog_id, $fallback_role ).
    • That method returns an array: [ $migrated_count, $skipped_count, $err_msg ].
  • Handling result:
    • If $err_msg is not empty, it is shown as an error.
    • Otherwise, a success message shows:
      • How many users were added to the target site.
      • How many were skipped because they already had access.

This is where the actual migration is triggered.

4.3 Keeping the form values after submit

// Get last used values (to keep form filled after submit)
$from_blog_val  = isset( $_POST['from_blog_id'] ) ? absint( $_POST['from_blog_id'] ) : '';
$to_blog_val    = isset( $_POST['to_blog_id'] ) ? absint( $_POST['to_blog_id'] ) : '';
$fallback_val   = isset( $_POST['fallback_role'] ) ? sanitize_text_field( $_POST['fallback_role'] ) : 'subscriber';

These variables are used to pre-fill the form fields:

  • If the form was submitted, the values remain in the inputs.
  • Good UX: the user does not need to type again if there is an error.

4.4 Outputting the page HTML

    ?>
    <div class="wrap">
        <h1><?php esc_html_e( 'Multisite User Migrator', 'ms-user-migrator' ); ?></h1>

        <p>Use this tool to assign users from one site (blog) in the network to another. Users already exist globally; this just attaches them to the target site with a role.</p>
        <p><strong>Note:</strong> This action cannot be easily undone. Always test on staging first.</p>

        <?php if ( $message ) : ?>
            <div id="message" class="updated notice is-dismissible"><p><?php echo esc_html( $message ); ?></p></div>
        <?php endif; ?>

        <?php if ( $error ) : ?>
            <div class="error notice is-dismissible"><p><?php echo esc_html( $error ); ?></p></div>
        <?php endif; ?>

        <form method="post">
            <?php wp_nonce_field( 'msum_migrate_users', 'msum_nonce' ); ?>

            <table class="form-table" role="presentation">
                <tr>
                    <th scope="row"><label for="from_blog_id">From Site ID</label></th>
                    <td>
                        <input name="from_blog_id" type="number" id="from_blog_id" value="<?php echo esc_attr( $from_blog_val ); ?>" class="regular-text" required />
                        <p class="description">Source site (blog) ID. You can see it in Network Admin → Sites (hover the site name and check <code>id=</code> in the URL).</p>
                    </td>
                </tr>
                <tr>
                    <th scope="row"><label for="to_blog_id">To Site ID</label></th>
                    <td>
                        <input name="to_blog_id" type="number" id="to_blog_id" value="<?php echo esc_attr( $to_blog_val ); ?>" class="regular-text" required />
                        <p class="description">Target site (blog) ID where users should be added.</p>
                    </td>
                </tr>
                <tr>
                    <th scope="row"><label for="fallback_role">Fallback Role</label></th>
                    <td>
                        <input name="fallback_role" type="text" id="fallback_role" value="<?php echo esc_attr( $fallback_val ); ?>" class="regular-text" />
                        <p class="description">Role used if a user has no role on the source site. Default: <code>subscriber</code>. Example values: <code>administrator</code>, <code>editor</code>, <code>author</code>, <code>contributor</code>, <code>subscriber</code>.</p>
                    </td>
                </tr>
            </table>

            <p class="submit">
                <input type="submit" name="msum_submit" id="msum_submit" class="button button-primary" value="Run Migration">
            </p>
        </form>
    </div>
    <?php
}

 

Key points:

  • <div class="wrap"> and <h1>:
    • Standard WordPress admin page layout.
  • Intro paragraphs:
    • Explain to the admin what the tool does.
  • Success message:
    • Shows in a green .updated notice box if $message is set.
  • Error message:
    • Shows in a red .error notice box if $error is set.
  • <form method="post">:
    • Uses POST to submit.
  • wp_nonce_field( 'msum_migrate_users', 'msum_nonce' );:
    • Adds a hidden msum_nonce field for security.
  • Form fields:
    • From Site ID (number input)
    • To Site ID (number input)
    • Fallback Role (text input)
  • Submit button:
    • <input type="submit" name="msum_submit" ... value="Run Migration">
    • Name msum_submit is used earlier to detect when the form was submitted.

This entire block is just responsible for the UI and validation feedback.

5. The migration logic

/**
 * Migrate users from one blog to another.
 *
 * @return array [migrated_count, skipped_count, error_message]
 */
protected function migrate_users( $from_blog_id, $to_blog_id, $fallback_role = 'subscriber' ) {
    global $wpdb;

    // Check if both blogs exist
    $from_blog = get_blog_details( $from_blog_id );
    $to_blog   = get_blog_details( $to_blog_id );

    if ( ! $from_blog || ! $to_blog ) {
        return array( 0, 0, 'One or both site IDs do not exist in this network.' );
    }

 

5.1 Validating blog IDs

  • Uses get_blog_details( $blog_id ):
    • If either returns false, that blog does not exist.
    • In that case the function returns:
      • migrated: 0
      • skipped: 0
      • error message: 'One or both site IDs do not exist in this network.'

5.2 Getting users from the source blog

// Get users from source blog
$user_ids = get_users( array(
    'blog_id' => $from_blog_id,
    'fields'  => 'ID',
) );

if ( empty( $user_ids ) ) {
    return array( 0, 0, 'No users found on the source site.' );
}
  • get_users() with blog_id:
    • Gets users that have a role on the source blog.
    • fields => 'ID' returns only user IDs (not full user objects) to be more efficient.
  • If no users found:
    • Early return with a friendly message.

5.3 Figuring out meta keys for capabilities

$from_prefix = $wpdb->get_blog_prefix( $from_blog_id );
$to_prefix   = $wpdb->get_blog_prefix( $to_blog_id );

$from_cap_key = $from_prefix . 'capabilities';
$to_cap_key   = $to_prefix . 'capabilities';
  • In multisite, each site has its own table prefix, e.g.:
    • Main site: wp_
    • Site 2: wp_2_
    • Site 3: wp_3_ etc.
  • User roles are stored in user meta under keys like:
    • wp_capabilities
    • wp_2_capabilities
    • wp_3_capabilities
  • Here, it builds:
    • $from_cap_key e.g. wp_2_capabilities
    • $to_cap_key e.g. wp_5_capabilities

These keys are used to get/set user capabilities per site.

5.4 Looping over users and migrating

        $migrated_count = 0;
        $skipped_count  = 0;

        foreach ( $user_ids as $user_id ) {

            // If user already has caps on target blog, skip
            $existing_caps = get_user_meta( $user_id, $to_cap_key, true );
            if ( is_array( $existing_caps ) && ! empty( $existing_caps ) ) {
                $skipped_count++;
                continue;
            }

            // Determine role from source site
            $caps = get_user_meta( $user_id, $from_cap_key, true );
            $role = $fallback_role;

            if ( is_array( $caps ) && ! empty( $caps ) ) {
                $roles = array_keys( $caps );
                if ( ! empty( $roles[0] ) ) {
                    $role = $roles[0];
                }
            }

            // Attach user to target blog
            add_user_to_blog( $to_blog_id, $user_id, $role );
            $migrated_count++;
        }

        return array( $migrated_count, $skipped_count, '' );
    }
}

 

Step-by-step inside the loop

  1. foreach ( $user_ids as $user_id ):
    • Loop through each user that has a role on the source site.
  2. Check if user already has a role on the target blog:
    $existing_caps = get_user_meta( $user_id, $to_cap_key, true );
    if ( is_array( $existing_caps ) && ! empty( $existing_caps ) ) {
        $skipped_count++;
        continue;
    }
    
    • Reads the capabilities for the target site.
    • If user already has some caps (already associated with that blog), it:
      • Increments $skipped_count
      • Uses continue to skip to the next user.
  3. Get role from source site:
    $caps = get_user_meta( $user_id, $from_cap_key, true );
    $role = $fallback_role;
    
    if ( is_array( $caps ) && ! empty( $caps ) ) {
        $roles = array_keys( $caps );
        if ( ! empty( $roles[0] ) ) {
            $role = $roles[0];
        }
    }
    
    • $caps is an array like:
      array(
        'editor' => true
      )
      
    • array_keys( $caps ) gives an array of roles.
    • It takes the first role: $roles[0].
    • If nothing found, it uses $fallback_role (e.g. subscriber).
  4. Attach user to target blog:
    add_user_to_blog( $to_blog_id, $user_id, $role );
    $migrated_count++;
    

    Core function that:

    • Adds the user to the target blog.
    • Assigns them the role determined above.
  5. At the end:
    return array( $migrated_count, $skipped_count, '' );
    

    Returns:

    • Number of users migrated
    • Number skipped
    • Empty error message (success)

6. Bootstrapping the class

new MS_User_Migrator();
  • This line creates an instance of the MS_User_Migrator class.
  • Triggers the __construct() method.
  • Registers the menu and makes everything work.

 

Primary Sidebar

A little about the Author

Having 9+ Years of Experience in Software Development.
Expertised in Php Development, WordPress Custom Theme Development (From scratch using underscores or Genesis Framework or using any blank theme or Premium Theme), Custom Plugin Development. Hands on Experience on 3rd Party Php Extension like Chilkat, nSoftware.

Recent Posts

  • How to Migrate Users Between Sites in WordPress Multisite (One-Time Task)
  • How to Deploy Django on Ubuntu with Apache2 & mod_wsgi (Complete Step-by-Step Guide)
  • How to add share via email functionality after each item’s content using filters
  • How to modify the site generator meta tag using filters
  • How to use WordPress path utility functions to load external files and images

Copyright © 2025 · Vinay Vengala