← Back to blog

How to check a WordPress migration did not corrupt serialized data

The SandyWP team 7 min read

Run the migration onto a disposable copy first, then loop over wp_options and every meta table, call is_serialized() on each value and try to unserialize() it, and count the rows that fail. Compare that list against the same list taken on the source site, and ship only if the migration added nothing to it.

Every guide about serialized data tells you to use a serialization-aware tool. None of them tell you how to check whether it worked, which is the part that matters, because the failure is silent.

Why a broken row does not announce itself

A serialized string carries a byte count for every string it holds. s:19:"https://example.com" says nineteen bytes. Replace the domain with something a different length and leave the 19 alone, and the whole row stops being valid serialized data.

What happens next is the interesting part. WordPress reads that row through maybe_unserialize(), which is documented as "Unserializes data only if it was serialized". It runs is_serialized() first, and when that check fails it returns the input unchanged.

So the plugin asked for its settings array and got a string. $settings['api_key'] on a string is null, the settings page falls back to its defaults, and the form renders normally with empty fields.

No PHP error. No white screen. No entry in debug.log unless the code happens to foreach over the value and you have WP_DEBUG on. The site looks migrated.

wp db check will not find this

This is the check people reach for, and it is the wrong one. WP-CLI documents wp db check as running "mysqlcheck utility with --check".

mysqlcheck validates tables and indexes. A corrupted serialized value is a perfectly well-formed row in a perfectly healthy longtext column, so the table passes and the data is still wrong.

The same goes for the WordPress repair tool at wp-admin/maint/repair.php. It repairs and optimises tables. It has no opinion about what is inside them.

Where the corruption actually comes from

Not from wp search-replace. WP-CLI documents that "search/replace intelligently handles PHP serialized data", and the migration plugins do the same thing: unserialize, replace, reserialize.

The damage comes from the byte-level shortcuts around the edges.

  • A raw UPDATE ... REPLACE() in SQL, or a find and replace in phpMyAdmin, or sed over a .sql dump. This is the classic one.
  • A database client that rewrites quoting on import. TablePlus has an open issue for exactly this, where an SQL import doubles quotes inside serialized values.
  • A utf8mb4 dump imported into a utf8 database. Multibyte characters get mangled on the way in, the byte counts no longer match the bytes, and rows that were fine at export are broken at import. Nothing in the process reports a failure.
  • A partial or truncated dump, where a large serialized value is cut off mid-string.

Two of those four have nothing to do with search and replace, which is why "I used a proper migration plugin" is not the same as "the data is intact".

The check, as an assertion

Run this on the migrated copy. It walks the four core tables that hold serialized values and prints every row that claims to be serialized and is not.

wp eval '
global $wpdb;
$targets = array(
  array( $wpdb->options,  "option_id", "option_name", "option_value" ),
  array( $wpdb->postmeta, "meta_id",   "meta_key",    "meta_value" ),
  array( $wpdb->usermeta, "umeta_id",  "meta_key",    "meta_value" ),
  array( $wpdb->termmeta, "meta_id",   "meta_key",    "meta_value" ),
);
$broken = 0;
foreach ( $targets as $t ) {
  list( $table, $id, $key, $value ) = $t;
  $rows = $wpdb->get_results( "SELECT $id AS id, $key AS k, $value AS v FROM $table" );
  foreach ( $rows as $row ) {
    if ( ! is_serialized( $row->v ) ) { continue; }
    if ( @unserialize( $row->v ) === false && $row->v !== "b:0;" ) {
      echo "BROKEN  $table  #{$row->id}  {$row->k}\n";
      $broken++;
    }
  }
}
echo "$broken broken rows\n";
'

Two details in there are load-bearing.

The is_serialized() guard means you only test values that were meant to be serialized, so ordinary strings that happen to start with a letter and a colon do not register as failures.

The b:0; exception is there because unserialize() returns false for a correctly serialized false. Without that line the check reports healthy boolean options as corrupt, you stop trusting it, and you stop running it.

Zero is the wrong pass criterion

Run the same command on the source site before you migrate. On a site more than a couple of years old the count is usually not zero.

Old plugins leave broken rows. A migration in 2022 left three. Someone edited an option by hand once. None of that is your problem today.

The thing you are testing is whether this migration made it worse, so save both lists and diff them.

wp eval '...' > /tmp/before.txt   # on the source
wp eval '...' > /tmp/after.txt    # on the migrated copy
diff /tmp/before.txt /tmp/after.txt

An empty diff is a pass. Anything new in after.txt is a row this migration broke, and the row name tells you which plugin to go and look at.

What this check cannot see

It finds rows that fail to unserialize. That is a narrower claim than "the data is fine", and there are three ways a site can pass it and still be broken.

A value that is valid and wrong. If a replacement changed a string inside an array and correctly fixed the byte count, the row unserializes cleanly and holds the wrong value. A licence key pointing at the old domain, a Stripe webhook URL that no longer exists. The only way to catch these is to compare the actual values, which for the handful of settings that matter is faster than it sounds: wp option get <name> --format=json on both sites and diff.

Serialized objects. A row starting with O: names a PHP class. If the class does not exist on the destination, because the plugin is not installed or was renamed, PHP does not fail. It builds a __PHP_Incomplete_Class, which unserializes without error and throws a fatal the moment something calls a method on it. This check passes it.

Anything outside those four tables. Plugins with their own tables serialize into them too, and WooCommerce, form plugins and page builders all do. Add the table to the $targets array if you know it holds serialized columns. There is no way to discover them automatically.

Also worth saying plainly: if the value is being served from a persistent object cache, you are reading a cached copy and not the database at all. Flush it before you check.

Doing the whole thing on a throwaway copy

The reason this test is usually skipped is that there is nowhere to run it. The migration is the thing you are testing, and by the time it has run on the destination it is too late to find out it broke.

That is what a disposable site is for. Copy the live site into a SandyWP sandbox with the Cloner plugin, run the migration there exactly as you plan to run it in production, and run the check on the result. If the diff is not empty you have found it on a copy that nobody is using.

You need shell access to run wp eval at all, which every SandyWP sandbox has: SSH and SFTP are per-sandbox, and WP-CLI is preinstalled, so wp ssh my-site --cmd="wp eval '...'" from the CLI works without setting anything up. Turn on WP_DEBUG_LOG from Debug mode while you browse the migrated copy, because a row that unserializes into the wrong shape usually produces a PHP warning the first time something iterates over it.

If you migrate the same way often, save the source state as a Template so the before-and-after diff starts from a known baseline every time instead of from whatever the live site looks like this week.

When to not bother

If you are moving a site with wp search-replace --precise and nothing else touches the bytes, the risk is genuinely low. WP-CLI describes --precise as "Force the use of PHP (instead of SQL) which is more thorough, but slower", and running it with --dry-run first, documented as "Run the entire search/replace operation and show report, but don't save changes to the database", tells you what it would touch before it touches it.

Run the check when the migration involves a hand-edited dump, a change of database client, a collation change, or a plugin you have not migrated with before. That is where the corruption lives.

And if you are moving a live store or a client site, this is a rehearsal, not a substitute for a backup you have actually restored once.