WordPress Serialized Data: The Complete Guide to Safe Migrations, Search-Replace, and Debugging
What Serialized Data Actually Is and Why WordPress Uses It
Every WordPress site stores structured data inside flat database columns. Options, widget configurations, plugin settings, theme mods, post meta fields, transient caches, and user preferences all live as serialized strings in tables like wp_options, wp_postmeta, and wp_usermeta. PHP’s serialize() function converts arrays and objects into a compact string format that can be stored in a single longtext or varchar column without needing additional relational tables.
This approach gives WordPress flexibility. A plugin can store any arbitrary data structure without requiring schema changes. But that flexibility comes with a strict contract: the serialized string must be byte-accurate. One wrong character, one miscounted string length, and the entire data structure becomes unreadable. This is the root cause of thousands of broken sites after migrations, domain changes, and database search-replace operations.
Understanding the serialization format at a low level is not optional knowledge for anyone who manages WordPress databases. It is the foundation for safe migrations, reliable deployments, and effective debugging when things go wrong.
PHP Serialization Format: A Deep Technical Breakdown
PHP serialization encodes data types into a string representation using a consistent notation. Each type has a prefix letter followed by a colon and the value. Knowing these type markers lets you read raw serialized strings directly in the database.
Scalar Types
Strings use the format s:LENGTH:"VALUE"; where LENGTH is the byte count (not character count) of the string:
s:5:"hello";
s:11:"hello world";
s:0:"";
The byte count is critical. The string "hello" is 5 bytes. The string "hello world" is 11 bytes. PHP’s unserializer reads exactly that many bytes after the opening quote. If the byte count says 5 but the actual string is 11 bytes long, unserialization fails.
Integers are straightforward:
i:42;
i:0;
i:-7;
Floats (doubles) follow a similar pattern:
d:3.14159;
d:0;
d:-99.5;
Booleans use 1 and 0:
b:1;
b:0;
NULL is simply:
N;
Compound Types: Arrays
Arrays are encoded as a:COUNT:{KEY;VALUE;KEY;VALUE;...}. The COUNT is the number of key-value pairs. Keys can be integers or strings. Values can be any serialized type, including nested arrays.
A simple indexed array ['apple', 'banana', 'cherry'] serializes to:
a:3:{i:0;s:5:"apple";i:1;s:6:"banana";i:2;s:6:"cherry";}
An associative array ['name' => 'WordPress', 'version' => '6.4'] becomes:
a:2:{s:4:"name";s:9:"WordPress";s:7:"version";s:3:"6.4";}
Nested arrays work recursively. An array containing another array has the inner array serialized as a complete a:... block inside the outer array’s value position:
a:2:{s:4:"user";a:2:{s:4:"name";s:5:"Alice";s:5:"email";s:17:"[email protected]";}s:4:"role";s:6:"editor";}
Objects
Serialized objects use O:CLASSNAME_LENGTH:"CLASSNAME":PROPERTY_COUNT:{...}. You will occasionally see these in WordPress, especially with older plugins that stored object instances directly:
O:8:"stdClass":2:{s:4:"name";s:5:"Alice";s:3:"age";i:30;}
The number 8 is the byte length of the class name “stdClass”. Object serialization can cause additional problems during migrations because PHP needs the class definition available to unserialize the object. If you migrate a database to a server where the plugin that defined the class is not installed, unserialization will either fail or return an incomplete __PHP_Incomplete_Class object.
The Byte-Count Rule
This is the single most important detail about PHP serialization. The number after s: is bytes, not characters. For ASCII strings, bytes and characters are the same. But for multi-byte UTF-8 characters, they differ. The German word “Uber” with an umlaut, “Über”, has 4 characters but 5 bytes because “Ü” is encoded as two bytes in UTF-8. Serialized, it looks like:
s:5:"Über";
If a search-replace operation changes a string and produces a different byte length without updating the byte count prefix, the serialized data is corrupted. This is the central problem.
Why Simple Find-Replace Destroys Serialized Data
Consider a common scenario. You are migrating a WordPress site from https://staging.example.com to https://example.com. The staging URL appears hundreds of times in the database: in post content, option values, widget settings, theme mods, and serialized meta fields.
A naive approach is to run a SQL query:
UPDATE wp_options SET option_value = REPLACE(option_value, 'https://staging.example.com', 'https://example.com');
For non-serialized data, this works fine. If a post contains a plain URL, replacing it causes no structural issues. But consider a serialized option value that stores the site URL:
s:30:"https://staging.example.com/wp";
The string https://staging.example.com/wp is 30 bytes. After a blind SQL REPLACE, it becomes:
s:30:"https://example.com/wp";
The new string https://example.com/wp is only 22 bytes, but the byte count still says 30. PHP’s unserialize() will try to read 30 bytes starting after the opening quote. It will consume the closing quote, the semicolon, and part of the next serialized element. The entire data structure is now corrupt.
The symptoms vary. Sometimes you get a white screen. Sometimes widgets disappear. Sometimes theme settings reset to defaults. Sometimes a plugin silently falls back to default configuration because get_option() returned false after failing to unserialize the stored value. The frustrating part is that the data is still in the database. It just cannot be parsed.
This problem gets worse with nested serialization. A widget configuration might be a serialized array containing other serialized arrays. A page builder might store its layout as a serialized array where individual elements contain serialized strings. Corrupting one byte count in a deeply nested structure can make the entire configuration unrecoverable through automated tools.
Nested Serialization: The Real-World Complexity
WordPress does not just serialize simple key-value pairs. Many components use multiple layers of serialization, and understanding this is essential for both migrations and debugging.
Widget Settings
WordPress stores widget configurations in wp_options with option names like widget_text, widget_custom_html, or widget_categories. The value is a serialized array where each key is a widget instance number and each value is the widget’s settings array:
a:3:{i:2;a:4:{s:5:"title";s:12:"About WPKite";s:4:"text";s:145:"...some HTML content with URLs...";s:6:"filter";b:1;s:6:"visual";b:1;}i:3;a:4:{s:5:"title";s:10:"Contact Us";s:4:"text";s:89:"...more HTML...";s:6:"filter";b:1;s:6:"visual";b:1;}s:12:"_multiwidget";i:1;}
Each widget instance’s text field might contain HTML with domain-specific URLs. A search-replace must update the URL inside the text, recalculate the byte count for the text string, then recalculate the byte count for the enclosing serialized structure if that structure is itself stored inside another serialized wrapper.
Page Builder Data
Page builders like Elementor, Beaver Builder, and WPBakery store layout data as serialized (or JSON-encoded) post meta. Elementor stores its data in _elementor_data as a JSON string, which is itself stored in wp_postmeta. But other builders use serialized arrays with nested structures that can go four or five levels deep.
A typical Beaver Builder layout stored in _fl_builder_data might contain:
a:1:{i:0;O:8:"stdClass":7:{s:4:"node";s:13:"jk29f83hd92kf";s:4:"type";s:3:"row";s:6:"parent";N;s:8:"position";i:0;s:8:"settings";O:8:"stdClass":15:{...dozens of properties, some containing URLs...}}}
Each URL change requires recalculating the byte count at that level and every enclosing level. Manual repair of corrupted page builder data is time-consuming but sometimes necessary when automated tools fail to handle deeply nested structures.
ACF Repeater and Flexible Content Fields
Advanced Custom Fields stores repeater field data across multiple wp_postmeta rows. The repeater count is stored in one row, and each sub-field gets its own row with a naming pattern like repeater_0_fieldname. The values themselves might be serialized arrays. A flexible content field adds another layer: the layout name is stored in a separate meta row, and each layout’s fields follow the same numbered pattern.
When you have a repeater field where each row contains a WYSIWYG sub-field with embedded URLs, a migration must handle each individual meta row. The serialization is usually simpler per-row (often just a string value), but the sheer number of rows and the inter-row relationships make it easy to miss entries.
ACF also stores field group configurations as serialized data in wp_options (for local JSON sync) and in custom post types. If you have ACF fields that define default values containing URLs, those defaults also need updating during a migration.
Debugging Corrupted Serialized Data: Manual Repair Techniques
When serialized data is already corrupted, you need to fix it. Sometimes automated tools can handle this, but often you need to understand the format well enough to repair it by hand.
Identifying Corruption
The first step is finding which option or meta values are broken. You can query the database directly:
SELECT option_name, option_value
FROM wp_options
WHERE option_value LIKE '%s:%'
AND option_value NOT LIKE '';
Then test individual values with PHP:
$value = $wpdb->get_var("SELECT option_value FROM wp_options WHERE option_name = 'widget_text'");
$result = @unserialize($value);
if ($result === false && $value !== 'b:0;') {
echo "Corrupt: widget_text\n";
}
You can also use WP-CLI to check:
ddev wp eval '
$broken = [];
$options = $GLOBALS["wpdb"]->get_results("SELECT option_name, option_value FROM wp_options WHERE option_value LIKE \"%s:%\"");
foreach ($options as $opt) {
$test = @unserialize($opt->option_value);
if ($test === false && $opt->option_value !== "b:0;") {
$broken[] = $opt->option_name;
}
}
print_r($broken);
'
Manual Byte Count Repair
Once you find a corrupted value, pull it out and examine it. Suppose you have:
s:30:"https://example.com/wp";
Count the bytes in the actual string. https://example.com/wp is 22 bytes. Fix it:
s:22:"https://example.com/wp";
For longer serialized strings, use PHP’s strlen() to count bytes (not mb_strlen(), which counts characters):
echo strlen('https://example.com/wp'); // 22
The Regex Repair Approach
For automated repair of corrupted serialized strings, you can write a function that recalculates all string byte counts:
function fix_serialized_string($broken) {
$fixed = preg_replace_callback(
'/s:(\d+):"(.*?)";/s',
function ($matches) {
return 's:' . strlen($matches[2]) . ':"' . $matches[2] . '";';
},
$broken
);
return $fixed;
}
This approach has limitations. The lazy .*? pattern can match incorrectly when the serialized string itself contains "; sequences (which is rare but possible with encoded HTML). For production use, a proper parser that walks the serialized format character by character is safer.
Using WordPress Functions for Repair
If only the byte counts are wrong but the data is otherwise intact, WordPress’s maybe_unserialize() function will return the raw string rather than corrupted data. You can sometimes work around corruption by:
1. Extracting the raw string from the database.
2. Using a tolerant unserializer to parse what you can.
3. Manually reconstructing the array in PHP.
4. Re-serializing with serialize() and writing it back.
The maybe_serialize() and maybe_unserialize() functions in wp-includes/functions.php are worth studying. maybe_serialize() only serializes if the value is an array or object, or if it is a string that is already serialized. maybe_unserialize() checks whether a string looks serialized before attempting to unserialize it:
// From wp-includes/functions.php
function maybe_unserialize( $data ) {
if ( is_serialized( $data ) ) {
return @unserialize( trim( $data ) );
}
return $data;
}
function is_serialized( $data, $strict = true ) {
if ( ! is_string( $data ) ) {
return false;
}
$data = trim( $data );
if ( 'N;' === $data ) {
return true;
}
// ... additional checks for s:, a:, O:, b:, i:, d: prefixes
}
Tool Comparison: interconnectit vs WP-CLI vs go-search-replace
Three tools dominate the WordPress serialization-aware search-replace space. Each has distinct strengths and trade-offs.
interconnectit Search Replace DB
The interconnectit Search Replace DB script (often called “SRDB” or the “interconnect/it tool”) is a standalone PHP script you upload to your server. It provides a web interface for performing search-replace operations across all database tables.
How it works: The script queries each table, pulls rows in batches, runs PHP’s unserialize() on each value, performs the string replacement on the unserialized data structure (walking through arrays and objects recursively), then re-serializes the data and writes it back. Because it unserializes before replacing and re-serializes after, the byte counts are always correct.
Strengths:
- Handles deeply nested serialized data reliably.
- Web UI makes it accessible to non-command-line users.
- Dry-run mode shows changes before committing.
- Handles custom tables, not just WordPress core tables.
Weaknesses:
- Security risk if left on the server (always delete after use).
- Slow on large databases because it processes row by row in PHP.
- Memory limits can cause timeouts on shared hosting with large option values.
- No CLI mode, so it cannot be automated in deployment scripts without wrappers.
Best for: One-off migrations where you have server access and want a visual dry run.
WP-CLI search-replace
WP-CLI’s built-in search-replace command is the most commonly used tool for serialization-aware replacements in WordPress:
wp search-replace 'https://staging.example.com' 'https://example.com' --all-tables --precise
How it works: Like interconnectit, WP-CLI unserializes data, performs replacements recursively through the data structure, and re-serializes. The --precise flag forces it to use PHP serialization for every cell rather than a faster but less thorough regex-based approach.
Key flags:
--dry-run: Shows what would change without modifying data.--precise: Uses PHP unserialization instead of regex. Slower but more accurate.--all-tables: Includes non-WordPress tables (custom plugin tables, etc.).--skip-columns=<columns>: Excludes specific columns from replacement.--export=<file>: Outputs SQL statements instead of modifying the database directly.--network: Handles all sites in a multisite network.
Strengths:
- Ships with WP-CLI, no extra installation needed.
- Full CLI integration, perfect for scripts and CI/CD pipelines.
- Handles multisite with the
--networkflag or--urltargeting. - The
--exportflag lets you generate SQL files for review before applying. - Active maintenance and community support.
Weaknesses:
- Requires WP-CLI and a working WordPress installation to run.
- Can be slow on very large databases (millions of rows).
- PHP memory limits can be an issue with extremely large serialized values.
- The non-precise mode can miss or mishandle edge cases.
Best for: Most WordPress migrations and domain changes. The default choice for the majority of use cases.
Example with DDEV:
ddev wp search-replace 'https://old-domain.com' 'https://new-domain.com' --all-tables --precise --dry-run
go-search-replace (Automattic)
Automattic’s go-search-replace is a Go binary that performs serialization-aware search-replace on SQL dump files. Rather than connecting to a database and processing rows, it processes the SQL file itself.
How it works: The tool parses SQL INSERT statements in a dump file, identifies serialized strings, performs replacements while recalculating byte counts, and outputs a modified SQL file. Because it is written in Go and operates on files rather than live databases, it is significantly faster than PHP-based tools.
Strengths:
- Extremely fast. Can process multi-gigabyte SQL dumps in seconds.
- No PHP or WordPress required. Works on raw SQL files.
- Integrates well into CI/CD pipelines where you are working with database dumps.
- Low memory footprint compared to PHP-based alternatives.
Weaknesses:
- Only works on SQL dump files, not live databases.
- Less commonly used, so community support is thinner.
- Does not handle every edge case that PHP unserialization handles (particularly deeply nested objects).
- Requires a Go binary, which may not be available on all hosting environments.
Best for: Large-scale migrations, automated deployment pipelines, and situations where you are already working with SQL dump files.
Usage example:
go-search-replace -old "staging.example.com" -new "example.com" < dump.sql > migrated.sql
Which Tool Should You Use?
For most WordPress developers working with sites under a few hundred megabytes of database, WP-CLI search-replace with the –precise flag is the right choice. It is reliable, well-tested, and integrates into existing workflows without additional dependencies.
For large databases (WordPress VIP sites, WooCommerce stores with millions of orders, multisite networks with hundreds of subsites), go-search-replace operating on SQL dumps is significantly faster and less likely to hit memory limits.
For non-technical users doing a one-time migration, interconnectit provides a visual interface that reduces the chance of running the wrong command. Just remember to delete it immediately after use.
Handling Multi-Byte Characters (UTF-8) in Serialized Strings
Multi-byte character handling is one of the most common sources of serialized data corruption, and it catches experienced developers off guard.
The Core Problem
PHP’s serialize() function uses strlen() to calculate string lengths, which returns byte count, not character count. This is correct behavior. A serialized string like:
s:5:"Über";
…is valid because “Über” is 5 bytes in UTF-8 (the “Ü” character takes two bytes). PHP’s unserialize() will read exactly 5 bytes and correctly reconstruct the string.
The problem arises when something in the pipeline modifies the byte count or the character encoding.
Database Collation Mismatches
If your MySQL/MariaDB database uses latin1 encoding but your PHP application sends UTF-8 data, the database might silently convert characters. The two-byte UTF-8 representation of “Ü” might be stored as a single latin1 byte. When you read it back, the byte count no longer matches the serialized header.
To check your database encoding:
wp db query "SHOW VARIABLES LIKE 'character_set%';"
wp db query "SELECT DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE();"
Always use utf8mb4 for WordPress databases. WordPress has required this since version 4.2. If you are working with an older database that still uses utf8 or latin1, convert it before attempting any search-replace:
wp db query "ALTER DATABASE your_database CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
Double Encoding
Double encoding happens when UTF-8 data is encoded twice. The “Ü” character (U+00DC) is encoded in UTF-8 as bytes 0xC3 0x9C. If those bytes are then treated as latin1 characters and encoded to UTF-8 again, you get 0xC3 0x83 0xC2 0x9C, which is four bytes instead of two. The serialized byte count is now wrong relative to what the data should be.
You can detect double encoding by looking for the telltale pattern of “Ô followed by another character where you expect a single accented character. In the database:
wp db query "SELECT option_name FROM wp_options WHERE option_value LIKE '%Ã%';"
Fixing double encoding requires converting the data back to its proper encoding, which is a separate problem from serialization but often co-occurs with migration-related serialization issues.
Multi-Byte Aware Search-Replace
When performing search-replace with multi-byte strings, verify that the byte counts change correctly. If you replace München (8 bytes: M-ü-n-c-h-e-n, where ü is 2 bytes) with Munich (6 bytes), the byte count drops by 2. Tools like WP-CLI handle this automatically because they unserialize, replace, and re-serialize. But if you are writing custom migration code, always use strlen() (not mb_strlen()) when calculating byte counts for serialized strings.
Testing Multi-Byte Handling
Before running a migration on production, test with a known multi-byte value:
wp eval '
$test = serialize(["city" => "München", "greeting" => "こんにちは"]);
echo "Original: " . $test . "\n";
$unserialized = unserialize($test);
echo "City bytes: " . strlen($unserialized["city"]) . "\n";
echo "Greeting bytes: " . strlen($unserialized["greeting"]) . "\n";
'
The Japanese greeting “こんにちは” is 5 characters but 15 bytes in UTF-8 (each character is 3 bytes). Verifying that these byte counts round-trip correctly through your migration tool is a reliable smoke test.
Automating Search-Replace in Deployment Pipelines
Manual search-replace is error-prone and does not scale. Modern WordPress development workflows should automate domain replacement as part of the deployment pipeline.
Database Dump and Transform Pattern
The safest automated approach follows this pattern:
- Export the database from the source environment.
- Run serialization-aware search-replace on the dump file.
- Import the modified dump into the target environment.
Here is a shell script implementing this pattern:
#!/bin/bash
set -euo pipefail
SOURCE_URL="https://staging.example.com"
TARGET_URL="https://example.com"
DUMP_FILE="database-$(date +%Y%m%d-%H%M%S).sql"
echo "Exporting database..."
wp db export "$DUMP_FILE" --single-transaction
echo "Running search-replace..."
wp search-replace "$SOURCE_URL" "$TARGET_URL" \
--all-tables \
--precise \
--report-changed-only \
--log="search-replace-$(date +%Y%m%d).log"
echo "Verifying serialized data integrity..."
wp eval '
$tables = ["options", "postmeta", "usermeta"];
$prefix = $GLOBALS["wpdb"]->prefix;
$errors = 0;
foreach ($tables as $table) {
$full_table = $prefix . $table;
$column = ($table === "options") ? "option_value" : "meta_value";
$rows = $GLOBALS["wpdb"]->get_results("SELECT * FROM $full_table WHERE $column LIKE \"%s:%\"");
foreach ($rows as $row) {
$val = $row->$column;
if (@unserialize($val) === false && $val !== "b:0;") {
$errors++;
}
}
}
echo "Serialization errors found: $errors\n";
if ($errors > 0) { exit(1); }
'
echo "Migration complete."
CI/CD Integration with GitHub Actions
For teams using GitHub Actions or similar CI systems, you can automate database migration as part of deployment:
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup WP-CLI
run: |
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp
- name: Import and Transform Database
run: |
wp db import staging-dump.sql --allow-root
wp search-replace 'https://staging.example.com' 'https://example.com' \
--all-tables --precise --allow-root
wp search-replace '/var/www/staging' '/var/www/production' \
--all-tables --precise --allow-root
wp cache flush --allow-root
Note the second search-replace for file paths. Serialized data often contains absolute file paths (especially in plugin settings for upload directories, cache paths, and log file locations). Always replace both URLs and file paths during migration.
DDEV-Based Local Development Workflow
When using DDEV for local development, you can script database pulls from production or staging with automatic URL replacement:
#!/bin/bash
# pull-database.sh
set -euo pipefail
REMOTE_URL="https://example.com"
LOCAL_URL="https://wpkite.com"
echo "Pulling remote database..."
ssh production "wp db export -" | ddev wp db import -
echo "Replacing URLs..."
ddev wp search-replace "$REMOTE_URL" "$LOCAL_URL" --all-tables --precise
echo "Replacing file paths..."
ddev wp search-replace "/var/www/html" "/var/www/html" --all-tables --precise
echo "Flushing caches..."
ddev wp cache flush
ddev wp rewrite flush
echo "Done. Site available at $LOCAL_URL"
Multiple Replacements
Real migrations often require more than one search-replace pass. A site might have mixed HTTP/HTTPS references, www/non-www variations, or different path formats:
ddev wp search-replace 'https://www.example.com' 'https://wpkite.com' --all-tables --precise
ddev wp search-replace 'http://www.example.com' 'https://wpkite.com' --all-tables --precise
ddev wp search-replace 'https://example.com' 'https://wpkite.com' --all-tables --precise
ddev wp search-replace 'http://example.com' 'https://wpkite.com' --all-tables --precise
Order matters. Start with the most specific pattern (www + https) and end with the least specific (non-www + http) to avoid double-replacing.
Edge Cases: Serialized Data in Custom Tables and Non-Standard Formats
Not all serialized data lives in the standard WordPress tables. Plugins create their own tables, and those tables might store serialized data in columns that WP-CLI does not scan by default.
Custom Plugin Tables
WooCommerce stores order data in custom tables (wp_wc_orders, wp_wc_orders_meta in newer versions with HPOS enabled). Gravity Forms uses wp_gf_entry_meta. WP Rocket uses wp_wpr_rocket_cache. These tables may contain serialized data with domain-specific strings.
WP-CLI’s --all-tables flag scans every table in the database, including custom plugin tables. But it only processes columns that look like they contain serialized data. If a custom table stores serialized data in a column with an unusual name or format, WP-CLI might skip it.
To verify that custom tables are being processed:
ddev wp search-replace 'old-domain.com' 'new-domain.com' --all-tables --precise --dry-run --report-changed-only
Check the output for your custom table names. If a table is missing, you can target it explicitly:
ddev wp search-replace 'old-domain.com' 'new-domain.com' wp_custom_table --precise
JSON-Encoded Data
Some plugins store data as JSON rather than PHP serialized format. Elementor’s _elementor_data post meta is a JSON string. JSON does not have the byte-count problem that PHP serialization has, so a simple string replacement within JSON is usually safe. But if the JSON string is itself stored inside a serialized wrapper, you still need serialization-aware replacement for the outer layer.
WP-CLI handles this correctly: it unserializes the outer wrapper, finds the JSON string inside, replaces within it, and re-serializes with the correct byte count for the modified JSON string.
Base64-Encoded Serialized Data
Some plugins (especially visual composers and page builders) base64-encode their serialized data before storing it. This double-encoding means that a standard search-replace tool will not find the URLs because they are hidden inside the base64 encoding.
To handle this, you need to:
- Query for base64-encoded values.
- Decode them.
- Perform the serialized-aware search-replace.
- Re-encode to base64.
- Update the database.
WPBakery Visual Composer is a known offender here. Its shortcode content is often base64-encoded. You can handle this with a custom WP-CLI command or a migration script:
$posts = $wpdb->get_results("SELECT ID, post_content FROM wp_posts WHERE post_content LIKE '%[vc_%'");
foreach ($posts as $post) {
$content = preg_replace_callback(
'/\[vc_raw_html\](.*?)\[\/vc_raw_html\]/',
function($matches) use ($old_url, $new_url) {
$decoded = base64_decode($matches[1]);
$replaced = str_replace($old_url, $new_url, $decoded);
return '[vc_raw_html]' . base64_encode($replaced) . '[/vc_raw_html]';
},
$post->post_content
);
$wpdb->update('wp_posts', ['post_content' => $content], ['ID' => $post->ID]);
}
Serialized Data in wp_options Transients
WordPress transients stored in wp_options (with _transient_ and _site_transient_ prefixes) often contain serialized data with URLs. These are cached values that will be regenerated, so you might be tempted to skip them during migration. But some transients have long expiration times, and stale URLs in transients can cause subtle bugs that appear intermittently.
The safest approach is to delete all transients after a migration:
ddev wp transient delete --all
ddev wp transient delete --all --network # for multisite
Or target them in the search-replace and let them be overwritten naturally.
Writing Safe Migration Scripts That Handle Serialized Data
When you need more control than WP-CLI provides, writing a custom migration script is sometimes necessary. Here is a pattern for a migration script that safely handles serialized data.
The Core Recursive Replacer
The heart of any serialization-safe migration is a recursive function that walks through data structures:
function wpkite_recursive_replace($search, $replace, $data) {
if (is_string($data)) {
return str_replace($search, $replace, $data);
}
if (is_array($data)) {
$replaced = [];
foreach ($data as $key => $value) {
$new_key = is_string($key)
? str_replace($search, $replace, $key)
: $key;
$replaced[$new_key] = wpkite_recursive_replace($search, $replace, $value);
}
return $replaced;
}
if (is_object($data)) {
$props = get_object_vars($data);
foreach ($props as $key => $value) {
$data->$key = wpkite_recursive_replace($search, $replace, $value);
}
return $data;
}
return $data;
}
Processing a Database Table
To process an entire table safely:
function wpkite_migrate_table($table, $search, $replace) {
global $wpdb;
$columns = $wpdb->get_results("SHOW COLUMNS FROM $table");
$primary_key = null;
foreach ($columns as $col) {
if ($col->Key === 'PRI') {
$primary_key = $col->Field;
break;
}
}
if (!$primary_key) {
error_log("No primary key found for $table, skipping.");
return 0;
}
$total_rows = $wpdb->get_var("SELECT COUNT(*) FROM $table");
$batch_size = 1000;
$updated = 0;
for ($offset = 0; $offset < $total_rows; $offset += $batch_size) {
$rows = $wpdb->get_results(
"SELECT * FROM $table LIMIT $batch_size OFFSET $offset",
ARRAY_A
);
foreach ($rows as $row) {
$changes = [];
foreach ($row as $column => $value) {
if ($column === $primary_key || empty($value)) {
continue;
}
if (strpos($value, $search) === false) {
continue;
}
$unserialized = @unserialize($value);
if ($unserialized !== false || $value === 'b:0;') {
// Value is serialized
$new_data = wpkite_recursive_replace($search, $replace, $unserialized);
$new_value = serialize($new_data);
} else {
// Value is not serialized, plain replace
$new_value = str_replace($search, $replace, $value);
}
if ($new_value !== $value) {
$changes[$column] = $new_value;
}
}
if (!empty($changes)) {
$wpdb->update($table, $changes, [$primary_key => $row[$primary_key]]);
$updated++;
}
}
}
return $updated;
}
Adding Logging and Rollback
A production migration script should log every change and support rollback:
function wpkite_migrate_with_logging($table, $search, $replace) {
global $wpdb;
$log_table = $wpdb->prefix . 'migration_log';
$wpdb->query("CREATE TABLE IF NOT EXISTS $log_table (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
table_name VARCHAR(255),
row_id BIGINT UNSIGNED,
column_name VARCHAR(255),
old_value LONGTEXT,
new_value LONGTEXT,
migrated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
// ... process table as above, but log each change:
if ($new_value !== $value) {
$wpdb->insert($log_table, [
'table_name' => $table,
'row_id' => $row[$primary_key],
'column_name' => $column,
'old_value' => $value,
'new_value' => $new_value,
]);
$changes[$column] = $new_value;
}
}
To rollback, iterate through the log table in reverse order and restore old values. This pattern costs storage space but provides a safety net that automated tools do not offer.
Validating After Migration
After running any migration, validate the results:
function wpkite_validate_serialized_data() {
global $wpdb;
$tables_to_check = [
$wpdb->options => 'option_value',
$wpdb->postmeta => 'meta_value',
$wpdb->usermeta => 'meta_value',
$wpdb->termmeta => 'meta_value',
];
$errors = [];
foreach ($tables_to_check as $table => $column) {
$rows = $wpdb->get_results(
"SELECT * FROM $table WHERE $column REGEXP '^[aOsi]:'"
);
foreach ($rows as $row) {
$value = $row->$column;
$test = @unserialize($value);
if ($test === false && $value !== 'b:0;') {
$id_col = ($table === $wpdb->options) ? 'option_name' : 'meta_id';
$errors[] = [
'table' => $table,
'id' => $row->$id_col ?? 'unknown',
'preview' => substr($value, 0, 100),
];
}
}
}
return $errors;
}
Run this validation function after every migration and fail the deployment if errors are found.
Prevention: Storing Data in Ways That Avoid Serialization Problems
The best approach to serialized data problems is to minimize the amount of serialized data you create. While you cannot control how WordPress core and third-party plugins store data, you can make better choices in your own code.
Use JSON Instead of Serialization
JSON is a better storage format for structured data in most cases:
// Instead of this:
update_option('my_plugin_settings', $settings_array);
// Which stores: a:3:{s:4:"name";s:7:"WPKite";s:3:"url";...}
// Do this:
update_option('my_plugin_settings', wp_json_encode($settings_array));
// Which stores: {"name":"WPKite","url":"https://example.com",...}
// Reading:
$settings = json_decode(get_option('my_plugin_settings'), true);
JSON has several advantages over PHP serialization for stored data:
- No byte-count headers, so string replacements do not break the format.
- Language-agnostic: other tools and languages can read JSON natively.
- Human-readable in the database, making debugging easier.
- No object deserialization risk (PHP object injection vulnerability).
The trade-off is that JSON cannot represent PHP-specific types like objects with class information. For plugin settings and configuration data, this is rarely a limitation.
Note that update_option() will automatically serialize arrays and objects. If you pass a JSON string, WordPress stores it as a plain string, which is exactly what you want.
Avoid Storing Full URLs in Serialized Data
Instead of storing absolute URLs in your plugin’s configuration:
// Fragile: breaks on migration
$settings = [
'logo_url' => 'https://example.com/wp-content/uploads/logo.png',
'api_endpoint' => 'https://example.com/wp-json/my-plugin/v1/',
];
update_option('my_plugin_settings', $settings);
Store relative paths and construct full URLs at runtime:
// Migration-safe
$settings = [
'logo_path' => '/wp-content/uploads/logo.png',
'api_path' => '/wp-json/my-plugin/v1/',
];
update_option('my_plugin_settings', $settings);
// When you need the full URL:
$logo_url = home_url($settings['logo_path']);
$api_url = rest_url('my-plugin/v1/');
This approach eliminates the need for search-replace on your plugin’s data entirely. Use WordPress functions like home_url(), site_url(), content_url(), wp_upload_dir(), and rest_url() to construct full URLs at runtime.
Use WordPress Constants and Functions
For file paths, never store hardcoded absolute paths:
// Bad: hardcoded path breaks on migration or server change
update_option('cache_dir', '/var/www/html/wp-content/cache/');
// Good: store relative to a known base
update_option('cache_dir', 'cache/');
// At runtime:
$cache_dir = WP_CONTENT_DIR . '/' . get_option('cache_dir');
Separate Domain-Dependent Data from Configuration
Design your data storage so that domain-dependent values are isolated:
// All domain-dependent values in one place
update_option('my_plugin_urls', wp_json_encode([
'callback' => home_url('/my-plugin/callback/'),
'webhook' => home_url('/my-plugin/webhook/'),
]));
// Domain-independent configuration separate
update_option('my_plugin_config', wp_json_encode([
'cache_ttl' => 3600,
'debug_mode' => false,
'features' => ['feature_a', 'feature_b'],
]));
Even better, regenerate domain-dependent values on activation or on a settings save rather than storing them at all:
function my_plugin_get_callback_url() {
return home_url('/my-plugin/callback/');
}
function my_plugin_get_webhook_url() {
return home_url('/my-plugin/webhook/');
}
Custom Tables with Proper Schema Design
If your plugin stores significant amounts of structured data, consider using a custom table with individual columns rather than serializing everything into a single field:
// Instead of storing serialized data in wp_postmeta:
// meta_key: '_product_attributes'
// meta_value: a:3:{s:5:"color";s:3:"red";s:4:"size";s:1:"M";s:6:"weight";s:4:"1.5g";}
// Use a custom table:
$wpdb->query("CREATE TABLE {$wpdb->prefix}product_attributes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id BIGINT UNSIGNED NOT NULL,
attribute_name VARCHAR(255) NOT NULL,
attribute_value TEXT NOT NULL,
INDEX idx_product (product_id),
INDEX idx_name (attribute_name)
)");
This design is easier to query, easier to migrate, and eliminates serialization entirely. The trade-off is more complex schema management and the need for custom upgrade routines.
Real-World Migration Checklist
After covering the theory, here is a practical checklist for performing a WordPress migration that handles serialized data correctly.
Before Migration
- Full database backup. Use
wp db export backup-$(date +%Y%m%d).sqland verify the file is not empty. - Full file backup. Tar the entire WordPress directory, including
wp-content/uploads. - Document all URLs. List every URL variation that might appear in the database: with and without www, HTTP and HTTPS, with and without trailing slashes.
- Document file paths. List absolute file paths that differ between environments.
- Check database encoding. Verify the database, tables, and columns all use
utf8mb4. - Identify custom tables. List all tables beyond WordPress core that might contain serialized data.
- Test on staging first. Never run a migration directly on production without testing it on an identical copy.
During Migration
- Put the site in maintenance mode. Prevent new data from being written during migration.
- Transfer the database. Export from source, import to target.
- Run search-replace with –precise and –dry-run first. Review the dry-run output before committing changes.
- Run search-replace for all URL variations. Start with the longest/most specific pattern.
- Run search-replace for file paths. Do not forget paths like
/home/user/public_htmlvs/var/www/html. - Flush all caches and transients.
wp cache flush,wp transient delete --all. - Regenerate rewrite rules.
wp rewrite flush.
After Migration
- Validate serialized data. Run the validation function described earlier.
- Check the frontend. Visit the homepage, a blog post, a page with a page builder, and any pages with complex widgets.
- Check the admin. Verify widgets, menus, theme customizer settings, and plugin configuration pages.
- Check WooCommerce (if applicable). Verify product pages, cart, checkout, and order history.
- Test forms. Contact forms, login forms, and any AJAX-powered functionality.
- Run a broken link checker. Old URLs that were missed will show up as 404s or incorrect redirects.
- Monitor error logs. Watch
wp-content/debug.logfor unserialization warnings for the first 24-48 hours.
Advanced Techniques: Partial Serialized Data Recovery
Sometimes serialized data is so badly corrupted that automated tools cannot repair it. Maybe someone ran a raw SQL REPLACE on a production database, or a botched character encoding conversion mangled the byte counts throughout the entire wp_options table. In these situations, partial recovery techniques can save data that would otherwise be lost.
Extracting Readable Fragments
Even in heavily corrupted serialized data, individual strings are often still intact. You can extract them with a regex:
preg_match_all('/"([^"]{3,})"/', $corrupted_data, $matches);
$readable_strings = $matches[1];
This gives you a list of all quoted strings longer than 3 characters. From this list, you can often reconstruct the original data structure manually, especially for plugin settings where you know the expected keys and value types.
Using WordPress Default Values
Many WordPress functions accept a default parameter. If you know which option or meta value is corrupted, you can delete it and let WordPress or the plugin recreate it with defaults:
delete_option('corrupted_option_name');
// Next time the plugin loads, it will recreate this option with defaults
For theme mods:
remove_theme_mod('corrupted_mod');
// The customizer will use the theme's default value
For widgets, you can reset all widget settings:
// Nuclear option: reset all widgets to defaults
wp_set_sidebars_widgets([]);
This loses all widget configurations but is sometimes the fastest path to a working site when widget data is heavily corrupted.
The Last Resort: Selective Table Restoration
If you have a pre-corruption backup (which you should always have), you can restore specific tables or even specific rows:
# Export just wp_options from the backup
mysqldump -u root -p database_name wp_options --where="option_name='widget_text'" > widget_backup.sql
# Import it into the corrupted database
mysql -u root -p database_name < widget_backup.sql
This targeted restoration lets you fix specific corrupted values without reverting the entire database. Combine it with a fresh search-replace on the restored values to update URLs.
Performance Considerations for Large Databases
On sites with millions of rows in wp_postmeta (common for WooCommerce stores and membership sites), search-replace operations can take hours and consume significant server resources.
Batching
WP-CLI processes rows in batches internally, but you can control the batch size with --batch-size if memory is limited. Smaller batches use less memory but take longer due to additional queries.
Targeting Specific Tables
Rather than using --all-tables, target only the tables you know contain domain-specific data:
wp search-replace 'old.com' 'new.com' wp_options wp_posts wp_postmeta --precise
This skips tables like wp_comments, wp_links, and custom tables that might not need processing.
Parallel Processing
For very large databases, you can split the work across multiple processes. Export the database, split the dump file by table, process each file independently with go-search-replace, and concatenate the results:
mysqldump --single-transaction --tab=/tmp/dump database_name
for file in /tmp/dump/*.sql; do
go-search-replace -old "staging.example.com" -new "example.com" < "$file" > "${file}.migrated" &
done
wait
# Import all migrated files
Indexing
If you frequently need to find which rows contain specific strings, consider adding a full-text index to the meta_value column:
ALTER TABLE wp_postmeta ADD FULLTEXT INDEX ft_meta_value (meta_value);
This speeds up WHERE meta_value LIKE '%search%' queries dramatically. However, the index itself consumes storage and slows down writes, so only add it if you are doing frequent searches or audits.
Security Implications of Serialized Data
PHP object injection is a well-documented vulnerability class that exploits unserialize(). If an attacker can control the input to unserialize(), they can instantiate arbitrary objects, trigger magic methods (__wakeup(), __destruct()), and potentially execute arbitrary code.
WordPress mitigates this in several ways:
maybe_unserialize()validates the format before callingunserialize().- WordPress 6.2+ supports the
allowed_classesoption inunserialize()for restricting which classes can be instantiated. - The
sanitize_option()function validates option values before storage.
When writing custom code that processes serialized data, always use maybe_unserialize() rather than calling unserialize() directly. If you must call unserialize(), pass the allowed_classes parameter:
$data = unserialize($raw_value, ['allowed_classes' => false]);
// All objects will be converted to __PHP_Incomplete_Class
For migration scripts that process untrusted data, this is an important safety measure. A corrupted serialized string could theoretically contain a malicious object definition.
Multisite-Specific Serialization Challenges
WordPress Multisite adds another layer of complexity. Each subsite has its own set of tables (wp_2_options, wp_2_postmeta, etc.), and the main site’s tables (wp_options, wp_sitemeta) contain network-wide settings.
When migrating a multisite network:
# Replace across all network tables
wp search-replace 'old-network.com' 'new-network.com' --network --all-tables --precise
# Or target specific subsites
wp search-replace 'old-network.com' 'new-network.com' --url=old-network.com/subsite --precise
The --network flag tells WP-CLI to iterate through all subsites. Without it, only the current site’s tables are processed. This is a common mistake that leaves subsite data with old URLs.
Multisite domain mapping adds further complexity. If subsites use custom domains, each domain needs its own search-replace pass:
wp search-replace 'subsite1.old-network.com' 'subsite1.new-network.com' --url=subsite1.old-network.com --precise
wp search-replace 'custom-domain.com' 'new-custom-domain.com' --url=custom-domain.com --precise
The wp_blogs and wp_site tables also need updating, though WP-CLI handles these as part of the --network processing.
Bringing It All Together
Serialized data is fundamental to how WordPress stores structured information. The format is simple but unforgiving: a single incorrect byte count corrupts the entire data structure. Understanding the serialization format at a low level, using the right tools for search-replace operations, and designing your own data storage to minimize serialization dependencies are all essential skills for WordPress developers and site administrators.
The key takeaways:
Always use serialization-aware tools for search-replace. Never run raw SQL REPLACE on a WordPress database. WP-CLI with the --precise flag is the standard choice. For large databases, go-search-replace operating on SQL dumps is faster.
Always back up before migrating. If something goes wrong, a backup lets you start over. Without one, you may lose data permanently.
Validate after every migration. Run a serialization integrity check on wp_options, wp_postmeta, and wp_usermeta at minimum. Check custom tables too.
Design your data to avoid the problem. Store relative paths instead of absolute URLs. Use JSON instead of PHP serialization where possible. Let WordPress functions generate domain-specific values at runtime.
Handle multi-byte characters carefully. Verify that your database encoding is utf8mb4. Test with multi-byte strings before running production migrations.
Account for edge cases. Base64-encoded content, custom tables, transients, multisite tables, and object serialization all require specific handling that default tools may not cover.
Serialized data will remain part of WordPress for the foreseeable future. Backward compatibility guarantees mean that wp_options and wp_postmeta will continue to store serialized arrays for years to come. The developers and administrators who understand this format deeply, and who build their workflows around safe handling of serialized data, will avoid the data loss and broken sites that plague careless migrations.
Alex Petrov
Database engineer and WordPress plugin developer. Specializes in custom table design, query optimization, and data migrations. Previously worked in fintech.