← Blog

WordPress

WordPress multisite audit: what we check before quoting

An inherited WordPress multisite landed in our inbox last month: 38 subsites, 14 GB database, domain change due in three weeks. Here is what we audit first.

Jacob Molkenboer· Founder · A Brand New Company· 5 Jun 2024· 9 min
Open leather estate logbook with paper tabs, chartreuse sticky note, brass key, iron shipping tag, red wax seal on ivory.

An inherited WordPress multisite arrived in our inbox last month with a polite note: "Can you migrate this to a new server before the 30th?" Thirty-eight subsites, fourteen gigabytes of database, eleven years of accumulated plugin debt. The previous agency had stopped responding. The host had sent a final-warning email about CPU consumption. The marketing lead just wanted the blog to keep loading.

We did not quote the job that day. We ran the audit first. Here is the checklist, in the order we run it.

The first thirty minutes on a strange multisite

Before we touch a single configuration file, we want three things on screen: the wp-config.php, a phpMyAdmin or wp-cli connection, and the host's CPU graph for the last fourteen days. The wp-config tells us whether this is a true multisite or a single install wearing a costume (look for define('MULTISITE', true) and the SUBDOMAIN_INSTALL flag). The database connection tells us the table prefix, which matters more than people think on inherited builds. The CPU graph tells us whether there is a runaway cron job or a brute-force login attempt we are about to inherit alongside the code.

The CPU graph is the most-skipped step and the most useful. If the line spikes every fifteen minutes, you are looking at WP-Cron firing a stale Action Scheduler queue. If it spikes randomly with no pattern, that is almost always xmlrpc.php under a botnet. We have walked into both. The migration quote changes by thousands of euros depending on the answer.

The wp_options autoload query

The single most useful query on any inherited WordPress install is this one:

SELECT option_name, LENGTH(option_value) AS size
FROM wp_options
WHERE autoload = 'yes'
ORDER BY size DESC
LIMIT 20;

On a healthy multisite root, the total autoload = 'yes' payload sits under 1 MB. On the network we inherited last month, the top row was a 31 MB serialised array left behind by a long-deleted social-sharing plugin. Every request, on every subsite, every visitor, was loading that 31 MB into memory before WordPress did anything else. The wp_load_alloptions function loads every autoloaded row on every page boot. There is no caching of "just the ones I need."

The autoload bloat is the cheapest fix on the audit. One UPDATE statement, one cache flush, and TTFB drops by 300 to 800 ms across the network. We do not perform the fix during the audit itself. We measure. The number goes into the quote as a post-migration win we can demonstrate on the staging clone.

SELECT SUM(LENGTH(option_value))/1024/1024 AS autoload_mb
FROM wp_options
WHERE autoload = 'yes';

If that number is above 3 MB, you have inherited a problem. Above 10 MB, you have inherited a story. On multisite you run it against the network root and against every per-site options table (wp_2_options, wp_3_options, and so on). The fastest enumeration:

wp site list --field=url | while read url; do
  size=$(wp --url="$url" db query \
    "SELECT ROUND(SUM(LENGTH(option_value))/1024/1024,2) \
     FROM wp_options WHERE autoload='yes'" --skip-column-names)
  echo "$url autoload_mb=$size"
done

Orphan uploads and the disk lie

The host invoice says you have 84 GB of disk in use. The database backup is 1.2 GB. So 82.8 GB is "files." The natural assumption is that those files are uploads. Roughly half of them are not.

Run two numbers. First, the actual size of wp-content/uploads:

du -sh wp-content/uploads
du -sh wp-content/uploads/sites/*

Second, the number of attachment rows across every subsite:

wp site list --field=url | while read url; do
  count=$(wp --url="$url" post list --post_type=attachment --format=count)
  echo "$url attachments=$count"
done

If the file count on disk is dramatically higher than the attachment count in the database, you have orphans. Orphans come from three places: deleted attachment rows where the file was not removed, generated thumbnails from old image sizes that no longer exist, and backups dropped into wp-content/uploads by a plugin that should have known better.

We have seen 60 GB of orphan thumbnails on a single network because a previous developer registered fourteen custom image sizes and then removed twelve of them six months later. The image sizes were gone from the code. The generated files were not.

Decision rule: if the orphan ratio is above 30%, the migration quote includes a parallel rsync pass driven by a manifest of attachment IDs, not a flat copy of the uploads directory. That alone can shave a day off the cutover.

wp_blogs and the network table that breaks everything

Here is the table everyone forgets on a multisite migration: wp_blogs. It holds the canonical domain and path for every subsite on the network. WordPress reads from it on every request, before it even decides which subsite's options table to load. If you migrate the database to a new domain and dutifully update siteurl and home on every subsite, the front-end might look correct. The admin will break in subtle ways. wp-admin redirects will loop. Network admin will show the old domain. New subsite creation will fail. Cookies will be set against the wrong host.

Warning

On a multisite domain change, updating wp_options.siteurl is the easy half. If you do not also rewrite wp_blogs.domain, wp_site.domain, wp_sitemeta, and wp_blogmeta, the network admin will load and refuse to function.

The fix is one search-replace per environment, done through wp-cli so that serialised PHP arrays survive the operation intact:

wp search-replace 'old.example.com' 'new.example.com' \
  --network \
  --precise \
  --skip-columns=guid \
  --dry-run

Always dry-run first. Always skip the guid column (GUIDs are post identifiers, not URLs to rewrite, even though they look like URLs). The wp-cli search-replace docs spell out why; we have ignored that guidance exactly once, on a 2017 migration that produced a feed full of duplicate posts in every reader on the network. We do not ignore it twice.

wp_blogmeta arrived in WordPress 5.0 and is now the storage layer for per-site metadata that the network admin reads on every load. Stale entries here are the second-most-common cause of "the network admin loads but I cannot click anything." If your inherited network was set up before 5.0 and upgraded later, expect the table to exist and to be inconsistent.

The plugin sprawl tally

The Network Plugins screen lies. It tells you which plugins are network-activated. It does not tell you which plugins are individually activated on subsite 17 by an editor who left in 2021.

wp plugin list --status=active-network --format=csv > plugins-network.csv
wp site list --field=url | while read url; do
  wp --url="$url" plugin list --status=active --format=csv \
    | tail -n +2 | sed "s|^|$url,|"
done > plugins-per-site.csv

Three categories fall out of those two files: plugins active on every subsite that should be network-activated (cheap cleanup); plugins active on one subsite that the previous team forgot existed (audit candidate); and plugins not in the WordPress repository at all (custom code we now own). The third category is where migration estimates explode. A single custom plugin that hits the database on every page load on every subsite can triple the post-migration server bill. We read every PHP file in mu-plugins and in any wp-content/plugins/[custom-slug]/. Twenty minutes of reading saves a week of debugging.

We also cross-reference active plugin slugs against the WPScan vulnerability database. The number of unpatched CVEs in a typical inherited network is uncomfortable. The quote needs to say which fixes are in scope and which the client is choosing to defer.

Cron, transients, and the things that fire on every request

Two more queries:

SELECT COUNT(*) FROM wp_options
WHERE option_name LIKE '_transient_%';

SELECT COUNT(*) FROM wp_options
WHERE option_name LIKE '_site_transient_%';

These should be low-hundreds numbers. If you see tens of thousands, a plugin is creating transients without an expiry, or failing to clean them up. Action Scheduler, used by WooCommerce and many newsletter plugins, can leave behind a wp_actionscheduler_actions table with millions of rows. That single table can be larger than the rest of the database combined.

Then check the WP-Cron schedule:

wp cron event list --fields=hook,next_run_relative,recurrence

If you see an event scheduled fifteen minutes in the past and the "next run" reads as "ago," WP-Cron is not firing. Either a system cron has stopped, or the host disabled WP-Cron without putting an external trigger in place. Either way, the migration quote includes a server-side cron entry for wp cron event run --due-now on the new host.

What the quote actually contains

Once we have the autoload number, the orphan ratio, the wp_blogs domain map, the plugin tally, and the cron status, we know what the migration is. We know which subsites can be dropped from the cutover (there is almost always one "test" subsite that has not been touched in four years). We know whether we can rsync the uploads or whether we need a manifest-driven copy. We know whether the new server needs Redis for object cache. We know whether the client should let one plugin licence lapse before we touch anything. The quote is honest because the numbers are honest.

Takeaway

The first query on any inherited WordPress install is the wp_options autoload size. It tells you what every page is paying, before WordPress runs a single line of your theme.

When we took on the 38-site network we opened with, the autoload payload turned out to be 31 MB, the orphan ratio was 47%, and wp_blogs still listed eight subsites that had been "deleted" through the admin but were quietly serving traffic to old URLs. We folded all of that into a four-stage legacy migration that finished a day under deadline. The audit is the part that makes the rest predictable.

Open a database client against the WordPress install you are most worried about. Run the autoload query at the top of this post. If the result is above 3 MB, you have your first thirty-minute project for tomorrow.

Key takeaway

The first query on any inherited WordPress install is the wp_options autoload size. It tells you what every page is paying before your theme runs.

FAQ

What is a safe autoload size for wp_options?

Under 1 MB on a healthy install. Between 1 and 3 MB is acceptable. Above 3 MB indicates plugin debt; above 10 MB the site is paying that cost on every request.

Why does updating siteurl break a multisite admin after a domain change?

WordPress reads wp_blogs and wp_site before it loads any subsite's options. If those network tables still point at the old domain, network admin, login cookies, and subsite creation all misbehave.

How do I find orphan uploads on a WordPress multisite?

Compare du -sh of wp-content/uploads/sites against the attachment count per subsite from wp post list. A large gap means generated thumbnails or plugin backups are sitting on disk with no database row.

Is wp-cli search-replace safe to use across a multisite database?

Yes, with --network, --precise, and --skip-columns=guid, after a dry run. It handles serialised PHP arrays correctly, which a raw SQL REPLACE will silently corrupt.

wordpressmysqllegacy sitesmigrationoperationsphp

Building something?

Start a project