Skip to content

wp search-replace and Serialized Strings: Why a Plain Replace Can Corrupt Your Data

wp search-replace and Serialized Strings: Why a Plain Replace Can Corrupt Your Data

Changing a site URL, moving to a new domain, or bringing production data into a development environment all require replacing strings across the database. WordPress has a WP-CLI command for this: wp search-replace.

How is it different from an SQL REPLACE() or running sed over a dump file? The answer: it can replace text without breaking PHP serialized strings. This article shows how the breakage happens, using a small PHP experiment. It is a general explanation, not a description of any specific product.

Note: Output below comes from PHP 8.5 run on October 16, 2026. The lengths depend only on the strings, so they do not vary by environment.

What a serialized string is

When WordPress stores an array or object in a single database column, it converts it with PHP’s serialize(). Many theme, widget and plugin settings in wp_options are saved this way.

We serialized a small array containing URLs:

$opt = ['site' => 'http://old.example.com',
        'menu' => ['home' => 'http://old.example.com/', 'title' => 'Old Site']];
echo serialize($opt);
a:2:{s:4:"site";s:22:"http://old.example.com";s:4:"menu";a:2:{s:4:"home";s:23:"http://old.example.com/";s:5:"title";s:8:"Old Site";}}

Look at s:22:"...". s means string, and 22 is the number of bytes in that string. The format stores a length next to every value, and unserialize() relies on it to know where each value ends.

What a plain string replace does

We replaced old.example.com (15 characters) with staging.example.net (19 characters) directly in the serialized text:

$naive = str_replace('old.example.com', 'staging.example.net', serialize($opt));
var_dump(@unserialize($naive));

The result was bool(false): the data could not be read. The values had changed, but the declaration still said s:22 while the real value was 26 bytes. unserialize() treats that mismatch as a corrupt format.

The troubling part is that the replacement itself looks successful. The SQL completes and reports affected rows. The damage shows up later, when a screen that uses the option opens: settings fall back to defaults, or widgets disappear.

Recounting the length fixes it

The correct approach is to unserialize, replace inside the values, and serialize again:

function rep($v) {
  if (is_string($v)) return str_replace('old.example.com', 'staging.example.net', $v);
  if (is_array($v))  return array_map('rep', $v);
  return $v;
}
$ok = serialize(rep(unserialize(serialize($opt))));

The declarations became s:26 and s:27, and unserialize() succeeded and matched the expected array.

a:2:{s:4:"site";s:26:"http://staging.example.net";s:4:"menu";a:2:{s:4:"home";s:27:"http://staging.example.net/";s:5:"title";s:8:"Old Site";}}

wp search-replace detects serialized values and does exactly this: read, replace, serialize again. That is why length-changing replacements are safe with it.

Exception: same length passes by luck

We also tried a same-length replacement. old.example.com and new.example.xyz are both 15 characters, and the plain replace still unserialized fine, because no declaration changed.

But that is luck, not safety. While your domains happen to match in length nothing shows up; the first time they differ, data breaks. “It worked last time” is not evidence.

Non-English text: bytes, not characters

The length is in bytes. Serializing 'あいう' gave s:9:"あいう": three characters, but 3 bytes each in UTF-8. Counting characters by hand while editing a dump will not match, which is another reason not to manage lengths manually.

JSON has a different pitfall

Some values, such as block editor settings, are stored as JSON. JSON has no length declarations, so that problem does not occur. Instead, slashes may be stored escaped. In our experiment, json_encode turned http://old.example.com/ into http:\/\/old.example.com\/.

If a value is stored that way, searching for http://old.example.com/ will not match, leaving some entries unreplaced. If the reported count is lower than expected, try searching for the escaped form too.

A safe order of work

  1. Back up first. The command rewrites the database directly. Keep a dump (for example with mysqldump) so you can go back.
  2. Run with --dry-run. It reports which tables and columns would match without changing anything.
  3. Limit the scope. If you do not need every table, name the ones you do. Some columns, such as guid, are normally left alone.
  4. Check the screens afterward. Open theme settings, widgets and menus, which use serialized values.
  5. Be suspicious of zero or very low counts. Case, http vs https, a trailing slash, or JSON escaping may mean your search string does not match the stored form.
# Preview only (changes nothing)
wp search-replace 'old.example.com' 'staging.example.net' --dry-run

# Run for real and check the reported counts
wp search-replace 'old.example.com' 'staging.example.net'

Summary

  • Many WordPress settings are stored as PHP serialized strings, which carry a byte length with every value
  • A length-changing plain replace breaks that declaration and unserialize() fails, while the replace itself appears to succeed
  • wp search-replace reads, replaces and re-serializes, avoiding the problem
  • Same-length replacements working is luck. Non-English text is counted in bytes
  • Build a backup, --dry-run, and a post-run screen check into the routine

For operations that rewrite data directly, it helps to ask whether running them again gives the same result. We cover that mindset in What Is Idempotency?.