Migrating Standalone WordPress Sites into Multisite Without WXR: A Complete DB-Merge Workflow

Every existing guide on this topic falls into one of two camps: marketing pages for a paid migration plugin that wave at the hard parts, or an “advanced administration” handbook page that hands you a checklist and wishes you luck. Neither gives you a repeatable process you can run against 5, 10, or 50 sites without holding your breath.

This post is the missing middle: a full WP-CLI-based workflow for merging standalone WordPress installs into a Multisite network via direct database import, with working scripts for the parts that usually go wrong — table prefix retargeting, user ID collisions, serialized data corruption, and media path reorganization.

Why not WXR export/import? WXR (WordPress’s native XML export format) is lossy. It doesn’t reliably carry menu location assignments, some widget configurations, certain plugin meta, or media-to-post relationships. For anything beyond a handful of blog posts, you’ll spend more time fixing what WXR dropped than you would have spent doing the DB merge properly. This guide skips it entirely.

Who this is for

You’re comfortable with SSH, MySQL, and WP-CLI. You have shell access to both the source sites and the destination multisite network (or can get a DB dump and file access to both). You’re merging somewhere between 2 and ~20 sites — past that, the same principles apply but you’ll want to fully script the loops rather than run steps by hand.

What you’ll need before starting

  • WP-CLI installed and working against both source and destination
  • Full backups of every source site (database + wp-content) — non-negotiable
  • Direct database access (not just SFTP) to source and destination
  • A list of every plugin/theme in use across all source sites
  • A list of every user (login, email, role) across all source sites

Phase 1: Stand up the multisite network

Do this on a fresh install, or convert an existing single site that will become the network’s primary site.

wp core multisite-convert --title="My Managed Network" --subdomains

Drop --subdomains for a subdirectory network (network.com/sitename) instead of subdomain (sitename.network.com). Subdirectory is usually simpler if you’re not mapping custom domains per site; subdomain is required if you plan to use domain mapping later, since each subsite needs a distinct hostname to map from.

After conversion, update .htaccess (Apache) or your nginx server block with the multisite rewrite rules WP-CLI prints out. Don’t skip this — subsites 404 without it.

Now create an empty subsite for each site you’re migrating:

wp site create --slug=site-a --title="Site A" --email=admin@yourdomain.com

wp site create --slug=site-b --title="Site B" --email=admin@yourdomain.com

Run wp site list --fields=blog_id,url and record every blog ID. You’ll need these for every step below.

Phase 2: Audit every source site before touching data

This is the step every guide skips, and it’s the one that prevents 2am surprises. For each source site:

# What plugins are actually active
wp plugin list --status=active --format=csv > site-a-plugins.csv

# What theme (and parent, if child theme)
wp theme list --status=active --format=csv > site-a-theme.csv

# Any custom tables outside core WP tables (WooCommerce, memberships, forms plugins, etc.)
wp db query "SHOW TABLES" | grep -v -E "^wp_(posts|postmeta|options|users|usermeta|terms|term_taxonomy|term_relationships|comments|commentmeta|links)$"

# Full user list with roles
wp user list --fields=ID,user_login,user_email,roles --format=csv > site-a-users.csv

Cross-reference the custom-tables output against your plugin list. WooCommerce, membership plugins (MemberPress, Restrict Content Pro), forms plugins with their own storage (Gravity Forms), and some SEO plugins all create tables outside the standard WP set. These tables do not get the wp_N_ treatment automatically — you have to identify them now and handle them explicitly in Phase 4, or their data silently doesn’t migrate.

Flag anything from site-a-plugins.csv that isn’t multisite-compatible. Search "[plugin name] multisite" for known issues before you’re mid-migration.

Phase 3: Reconcile users before you import anything

This is the step that causes the most damage when skipped, because the failure mode is silent: posts get reassigned to the wrong author, and nobody notices until someone asks “why does this old post say it was written by our office manager.”

Multisite has exactly one wp_users and wp_usermeta table, shared across the entire network. If two source sites both have a user with ID 3, and you naively import both, one will overwrite the other — and every post_author reference pointing at the old ID 3 on either site will now point at whichever user actually ended up with ID 3 on the network.

Build a mapping before import, not after.

#!/bin/bash

# build-user-map.sh

# Run against each source site, then reconcile manually or by email match.

SITE_SLUG=$1

wp user list --fields=ID,user_login,user_email --format=csv > "${SITE_SLUG}-users-source.csv"

Reconciliation logic:

1. For each source-site user, check if that email already exists as a user on the destination network:

wp user get <email> --field=ID --url=network.com 2>/dev/null

2. If it exists → record old_id -> existing_network_id in your mapping file. You will NOT import this user row; you’ll just add them to the new subsite with the right role.

3. If it doesn’t exist → import the user fresh (below), note the new ID WordPress assigns them, and record old_id -> new_id.

# Import a user that doesn't already exist on the network
wp user create newuser newuser@example.com --role=editor --url=site-a.network.com

# Note the returned ID — this is new_id in your mapping file

Save this mapping as a simple CSV per source site: old_id,new_id. You’ll feed it into the search-replace step in Phase 5 to rewrite post_author and any usermeta/serialized references that point at the old ID.

For 5-6 sites this is genuinely faster done by hand in a spreadsheet than automated — the judgment calls (is “j.smith@old.com” the same person as “jsmith@company.com”?) aren’t something you want a script guessing at.

Phase 4: Export and retarget each source database

#!/bin/bash

# export-and-retarget.sh

# Usage: ./export-and-retarget.sh <source-site-path> <old-prefix> <blog-id>

SOURCE_PATH=$1

OLD_PREFIX=$2 # usually "wp_"

BLOG_ID=$3

OUT="site-${BLOG_ID}-export.sql"

cd "$SOURCE_PATH" || exit 1

# 1. Clean the source DB first — don't migrate garbage

wp transient delete --all

wp post delete $(wp post list --post_status=trash --format=ids) --force 2>/dev/null

# 2. Export everything EXCEPT users/usermeta (those are handled separately in Phase 3)

TABLES=$(wp db query "SHOW TABLES" --skip-column-names | grep -v -E "^${OLD_PREFIX}(users|usermeta)$" | tr '\n' ',' | sed 's/,$//')

wp db export "$OUT" --tables="$TABLES"

# 3. Retarget the prefix to the new blog ID

NEW_PREFIX="wp_${BLOG_ID}_"

sed -i "s/\`${OLD_PREFIX}/\`${NEW_PREFIX}/g" "$OUT"

echo "Exported and retargeted: $OUT (prefix ${OLD_PREFIX} -> ${NEW_PREFIX})"

Run this once per source site, passing the correct blog ID each subsite got in Phase 1.

If Phase 2 flagged custom tables (WooCommerce, memberships, forms): add them explicitly to the --tables list, and make sure the sed prefix rename covers them too — the pattern above already catches any table starting with the old prefix, custom or not, as long as they use the same prefix convention. If a plugin used its own fixed prefix (some do), you’ll need a second sed pass for that specific table name.

Phase 5: Import, then fix author IDs, then serialized-safe search-replace

# Import the retargeted dump into the multisite database
wp db import "site-${BLOG_ID}-export.sql"

Now fix post authorship using your Phase 3 mapping, before running search-replace:

#!/bin/bash

# remap-authors.sh <blog-id> <mapping-csv>

BLOG_ID=$1

MAPPING=$2

while IFS=, read -r old_id new_id; do

wp db query "UPDATE wp_${BLOG_ID}_posts SET post_author = ${new_id} WHERE post_author = ${old_id}"

done < "$MAPPING"

Then run search-replace, scoped to just this subsite’s tables:

wp search-replace 'https://old-site-a.com' 'https://site-a.network.com' \

--url=site-a.network.com \

--network \

--all-tables-with-prefix \

--precise

--all-tables-with-prefix is what confines the operation to wp_${BLOG_ID}_* tables instead of touching the whole network. --precise forces a slower but byte-safe string match instead of a regex-based one, which matters when serialized PHP data is involved. This is the step that a raw SQL sed or find-and-replace tool gets wrong — serialized arrays store a string’s byte length as a prefix (s:19:"https://old-site-a.com"), so if you rewrite the string without also rewriting the length, PHP fails to unserialize the value and that setting (widget config, plugin option, whatever it was) silently breaks or disappears. WP-CLI’s search-replace recalculates the length automatically.

Always dry-run first:

wp search-replace 'old-site-a.com' 'site-a.network.com' --url=site-a.network.com --all-tables-with-prefix --dry-run

Read the output. If you see unexpectedly few or many replacements, stop and investigate before running it for real.

Phase 6: Media files

#!/bin/bash

# migrate-uploads.sh <source-uploads-path> <blog-id> <multisite-root>
SOURCE_UPLOADS=$1

BLOG_ID=$2

MS_ROOT=$3

DEST="${MS_ROOT}/wp-content/uploads/sites/${BLOG_ID}"

mkdir -p "$DEST"

rsync -av "${SOURCE_UPLOADS}/" "$DEST/"

After copying files, the subsite’s media library may not immediately reflect them — WordPress needs the wp_${BLOG_ID}_posts attachment rows (which came in with your DB import) to correctly point at the new path. Since search-replace in Phase 5 already rewrote guid and any serialized _wp_attachment_metadata entries from the old domain to the new one, this usually resolves itself. If images show as broken in the media library after import, regenerate metadata as a fallback:

wp media regenerate --url=site-a.network.com --yes

Phase 7: Plugins and themes

Decide network-wide vs. per-site before you import, using your Phase 2 audit:

# Network-activate anything common to all sites
wp plugin activate akismet wordfence --network

# Per-site activation for site-specific plugins
wp plugin activate woocommerce --url=site-a.network.com

Copy any theme/plugin files not already present into the shared wp-content/themes and wp-content/plugins directories (these are shared network-wide, unlike uploads). If a source site used a child theme, copy both the parent and child theme directories — activating a child theme without its parent present breaks the site silently.

Watch licensing: some premium plugins license per-install and may need re-activation or a support ticket to the vendor once they’re running under a multisite domain instead of the original standalone one.

Phase 8: Verify before you call it done

# Confirm no leftover old-domain references
wp search-replace 'old-site-a.com' 'x' --url=site-a.network.com --all-tables-with-prefix --dry-run

# Confirm attachment URLs resolve

wp post list --post_type=attachment --url=site-a.network.com --field=guid | head -20

# Confirm the right users have the right roles on this subsite

wp user list --url=site-a.network.com --fields=user_login,roles

Manually check: homepage, one blog post with images, one page with a form (if any), main menu, widgets/sidebar, and any WooCommerce or membership-gated content if applicable. These are the areas serialized-data corruption or missed custom tables show up first.

The failure modes this workflow specifically avoids

  • Serialized data corruption — avoided by using wp search-replace instead of raw SQL find/replace for every URL and prefix change touching content, not just the table-name rename.
  • User ID collisions silently reassigning post authorship — avoided by building and applying an explicit old→new ID mapping before any content import, not after.
  • Custom plugin tables (WooCommerce, memberships, forms) vanishing — avoided by auditing for non-core tables in Phase 2, before you’ve already run an export that excluded them.
  • Orphaned media — avoided by keeping the DB import (which carries correct attachment metadata) and the file copy (Phase 6) in sync, then regenerating metadata as a fallback rather than a first resort.

What this workflow doesn’t cover

Domain mapping DNS/SSL setup once subsites are live on custom domains, and any site using a page-builder that stores layout data in a way that doesn’t survive prefix changes cleanly (some Elementor/Divi configurations need a manual “regenerate CSS” pass post-migration).

If you’d rather not do this by hand

Everything above is free and gives you full control, but it’s manual labor — every phase is a script you run and a result you verify yourself. If you’re migrating a larger batch of sites, doing this regularly for clients, or just want the prefix retargeting and serialized-data handling done for you, WP Migrate Pro’s Multisite Tools add-on (from Delicious Brains) automates most of Phases 4 through 6: it handles table prefix conversion, user merging, media path reorganization, and serialized data updates as part of its push/pull workflow, rather than you scripting each step. It’s a paid tool, but for repeat migrations the time saved on verification alone tends to pay for itself. Worth a look if this workflow feels too advanced or like more manual work than your timeline allows.

Have your own question?

Ask me Here