The byte grammar, not the blob
A serialized value is a length-prefixed grammar. One wrong byte breaks it.
This is one real stored option. Each s:N declares exactly how many bytes the next string holds. Change the value without rewriting the count and unserialize() returns false, silently.
Why WordPress still uses PHP serialization and not JSON, byte by byte
A stored option is a length-prefixed grammar, not a blob. Here is the byte anatomy, why a naive search-replace corrupts it, the unserialize() object-injection landmine no one mentions, and when to stop.
Why does WordPress use PHP serialization instead of JSON for options and metadata?#
The short answer is type fidelity. WordPress stores arbitrary PHP values in text columns like wp_options.option_value and wp_postmeta.meta_value. Because those columns are plain text, a structured value has to be flattened into a string first. Therefore WordPress needs a format that can rebuild the exact PHP value later. PHP serialization is that format, and JSON is not.
Here is the crux of why WordPress uses PHP serialization instead of JSON. serialize() encodes the PHP type alongside the value. In contrast json_encode() throws the type away and keeps only a loose JSON shape. So a boolean, a float, and an object survive a serialize round-trip intact. However they degrade through a JSON round-trip. That single difference is the whole reason the legacy persists.
The maybe_serialize() and maybe_unserialize() contract#
WordPress does not serialize everything. Instead it serializes selectively through two small helpers. First maybe_serialize() checks the value type. Because scalars already survive a text column intact, a plain string or integer is stored raw. Only arrays and objects are handed to serialize(). Consequently most option rows are ordinary text, and only structured values carry the a: or O: grammar.
// wp-includes/functions.php (paraphrased from core).
// Scalars are stored raw. Only arrays and objects are serialized.
function maybe_serialize( $data ) {
if ( is_array( $data ) || is_object( $data ) ) {
return serialize( $data );
}
// A string that already LOOKS serialized is escaped once more, so a
// later maybe_unserialize() does not double-decode it by accident.
if ( is_serialized( $data, false ) ) {
return serialize( $data );
}
// Plain scalars (int, float, bool, string) go into the column verbatim.
return $data;
} // The read side. is_serialized() is a FORMAT SNIFF, not a safety check.
function maybe_unserialize( $original ) {
if ( is_serialized( $original ) ) { // does it look like a:/s:/O:/... ?
return @unserialize( trim( $original ) );
}
return $original; // not serialized: hand it back as is
}
// is_serialized() only asks "does this match the grammar?". It does NOT ask
// "is this safe to unserialize?". An O: token passes the sniff and is then
// handed straight to unserialize(), which instantiates the class. Read the second tab closely, because it matters later. is_serialized() is a format sniff, not a safety gate. It only asks whether the string looks like the grammar. Therefore an O: object token passes the sniff and flows straight into unserialize(). The core reference documents this contract in the maybe_serialize() documentation and its maybe_unserialize() counterpart. In short, the guard checks shape, never intent.
Native PHP type fidelity: what json_encode() loses#
The type-fidelity claim deserves a worked proof, not a hand-wave. Consider one row per native PHP type and compare the two round-trips. Because serialize() writes the type tag, each value returns as the same type it left as. However json_encode() collapses several distinct types into one JSON shape, so the decode cannot restore them. The matrix below states the exact outputs.
| PHP value | serialize() output | After a json_encode / json_decode round-trip |
|---|---|---|
| stdClass object | serialize() outputO:8:"stdClass":1:{...} rebuilds a real object | After a json_encode / json_decode round-tripBecomes a plain array or generic object; the class identity is gone |
| float 1/3 | serialize() outputd:0.33333333333333331; keeps the exact double | After a json_encode / json_decode round-tripSurvives as a number, but type intent and edge precision are not guaranteed |
| boolean false | serialize() outputb:0; stays a strict boolean | After a json_encode / json_decode round-tripStays false, yet mixed scalar columns often retype on the way back |
| null | serialize() outputN; stays null | After a json_encode / json_decode round-tripStays null, but empty-vs-null intent blurs against "" in loose code |
| [3 => "x"] int-keyed array | serialize() outputa:1:{i:3;s:1:"x";} keeps integer keys | After a json_encode / json_decode round-tripJSON object keys become strings, so 3 returns as "3" |
Notice the first and last rows especially. An object round-trips as a genuine object under serialize(), whereas JSON hands back a plain map with no class. Likewise an integer array key stays an integer, while JSON turns every key into a string. As a result code that relies on strict types keeps working after serialize() and quietly breaks after JSON. The PHP serialize() manual lists the full type-tag grammar behind these outputs.
Twenty years of stored data: the backward-compatibility lock#
Type fidelity explains the original choice. Backward compatibility explains why it can never change. Since 2005, billions of serialized rows have accumulated across every WordPress install, plugin table, backup, and migration export. Therefore switching the storage format would strand all of it. Moreover every plugin that calls get_option() or get_post_meta() expects the value to rebuild through unserialize(). So the format is frozen by its own success.
This is the honest reason WordPress uses PHP serialization instead of JSON today. It is not that JSON is unknown. WordPress ships wp_json_encode() and uses JSON widely in the REST API. Instead the options and metadata layer is load-bearing legacy, and the maybe_serialize() contract is the seam every plugin was written against. Consequently the cost of a format change is measured in the whole ecosystem, not one codebase.
Read the serialized string like a grammar you can decode by hand#
Most guides show you the blob and stop. This one teaches you to read it. Because the format is a small, regular grammar, you can decode any row with a short token vocabulary. Once you own the vocabulary, the corruption pitfall and the security landmine both become obvious rather than mysterious. So start with the tokens.
The token vocabulary: a: s: i: d: b: O: N#
Every serialized value opens with a one-letter type tag, and most tags carry a length or a count. The annotated row below names each token in order. First read the markers on the string, then check the numbered legend for what each one declares. In practice this is the whole skill: match every s:N prefix to the bytes that follow it.
- a:1Array of exactly one key-value pair. The count 1 says how many pairs live inside the braces.
- s:3A 3-byte string key: "url" is u-r-l, three bytes. s: always carries a byte length, not a character count.
- s:18An 18-byte string value: "http://example.com" is exactly 18 bytes. This is the prefix a naive rename miscounts.
- Other tokensi:42; is an integer, d:0.5; a double, b:1; a boolean, N; null, and O:8:"stdClass":1:{...} an object of a named class.
Two tags carry more than a length. First a: prefixes a count of pairs, then lists key and value alternately inside braces. Second O: prefixes a class-name length, the class name, and a member count, so unserialize() knows which class to rebuild. That O: form is the token to remember, because it is the one that turns a parse into code execution later.
Decode and repair a row yourself: the length prefix is the whole game#
Reading the grammar is one thing. Watching it break and heal is another. So this is the instrument the whole page promised. Edit the serialized blob, set a from and to rename, then flip between the naive rewrite and the serialization-aware rewrite. Because the bench reimplements the PHP-serialize walk in your browser, every s:N prefix recomputes live and the unserialize verdict flips in front of you.
Result after a serialization-aware rewrite
unserialize() succeedsa:1:{s:3:"url";s:17:"http://example.co";}- Declared prefix
- s:18
- Actual bytes now
- 17 bytes
- Aware path writes
- s:17
Using the serialization-aware path, unserialize() succeeds and the option round-trips. The renamed value is 17 bytes; the original prefix declared 18.
A faithful browser reimplementation of the PHP-serialize grammar the same walk WP-CLI performs, so you can watch the byte counts change. Edit the blob, the from value, or the to value and both paths recompute. The naive path is what a raw SQL REPLACE() does; the aware path is what serialization-aware tooling does.
Notice what the toggle proves. First the aware path always recounts the prefix, so the verdict stays valid however you rename. Then the naive path leaves the prefix stale, so any length change breaks the parse. Consequently the length prefix, not the string content, is the fragile part. That single insight drives the next section.
Why a naive search-replace silently corrupts your database#
Now apply the grammar to the most common WordPress migration task: changing a domain. A plugin stored an option with update_option('site_cfg', array('url' => 'http://example.com')). Because the value is an array, maybe_serialize() ran serialize() and the column holds a:1:{s:3:"url";s:18:"http://example.com";}. So far the bytes and the prefix agree.
The worked failure: example.com to example.co breaks the byte count#
Suppose the migration drops the trailing "m", so example.com becomes example.co. The value "http://example.co" is now 17 bytes, not 18. However a raw SQL REPLACE() rewrites only the visible text. Therefore the prefix still reads s:18 while the string holds 17 bytes. That one-byte gap is the entire failure, and the comparison below makes it something you can feel.
18 bytes
Prefix still declares (s:18)
17 bytes
String actually holds now
A single wrong byte is the whole difference between a working option and silent data loss: unserialize() reads 18 bytes for a 17-byte string, lands on the wrong closing byte, and returns boolean false.
| Option | byte length of the renamed string value |
|---|---|
| Prefix still declares (s:18) | 18 bytes |
| String actually holds now | 17 bytes |
The consequence is quiet, which is what makes it dangerous. Because unserialize() returns false rather than throwing, get_option('site_cfg') simply hands back a broken value. Meanwhile no error reaches the admin screen or the logs. As a result the setting is gone and nobody notices until a feature that depended on it fails downstream.
The landmine restated: raw UPDATE REPLACE corrupts silently#
The fix: serialization-aware tooling that recounts the prefix#
The safe path is not clever, it is just aware of the grammar. Instead of rewriting raw bytes, serialization-aware tooling unserializes each value, replaces inside the reconstructed PHP data, then re-serializes with a freshly counted prefix. So the s:18 becomes s:17 automatically. Compare the corrupting SQL against the command WordPress software engineers actually run.
-- The corrupting path. A raw column REPLACE() rewrites the bytes but NEVER
-- touches the s:18 length prefix in front of them.
UPDATE wp_options
SET option_value = REPLACE(option_value, 'http://example.com', 'http://example.co')
WHERE option_name = 'site_cfg';
-- Stored result (the value is now 17 bytes, the prefix still claims 18):
-- a:1:{s:3:"url";s:18:"http://example.co";}
--
-- On the next read, maybe_unserialize() -> unserialize() reads 18 bytes for a
-- 17-byte string, lands on the wrong closing byte, and returns boolean false.
-- get_option('site_cfg') silently returns a broken, empty value. No error. # The serialization-aware path. WP-CLI unserializes each value, replaces
# inside the reconstructed PHP data, then RE-SERIALIZES with a recounted prefix.
wp search-replace 'http://example.com' 'http://example.co' wp_options \
--recurse-objects \
--precise \
--dry-run \
--report-changed-only
# Drop --dry-run to write. The stored result rewrites the prefix to s:17:
# a:1:{s:3:"url";s:17:"http://example.co";}
#
# Now unserialize() reads 17 bytes for a 17-byte string and the option
# round-trips correctly. --recurse-objects also descends into O: values. The WP-CLI tool is the standard answer for a reason. First --precise forces the PHP unserialize walk instead of a faster regex that cannot see object boundaries. Then --recurse-objects descends into O: values too. The wp search-replace documentation covers the flags and their trade-offs. In short, let the tool recount the prefixes, because doing it by hand across a real database is where data dies.
The security landmine no one mentions: PHP object injection via unserialize()#
Here is the third of the intent that competitor pages skip entirely. unserialize() is not a passive parser. Because an O: token names a class, unserialize() instantiates that class and runs its lifecycle methods. Therefore feeding attacker-controlled bytes to unserialize() is a code-execution risk, not a data risk. This is precisely why JSON is the correct choice for any untrusted input.
What an O: token actually instantiates#
Recall the O: form from the grammar section: O:12:"Cache_Writer":2:{...}. When unserialize() meets it, it does not build an array. Instead it constructs a real Cache_Writer object and populates its properties from the following bytes. Then PHP fires the object magic methods, __wakeup() on rebuild and __destruct() on cleanup. So a parse becomes a method call on attacker-shaped state.
<?php
// A class that already exists in the loaded WordPress runtime. The attacker
// does not add code; they REUSE this class as a gadget.
class Cache_Writer {
public $path;
public $contents;
// Fires automatically when unserialize() rebuilds the object.
public function __wakeup() {
// Attacker controls $path and $contents through the serialized bytes.
file_put_contents( $this->path, $this->contents );
}
}
// The attacker stores this string in a postmeta row or a transient:
$payload = 'O:12:"Cache_Writer":2:{'
. 's:4:"path";s:20:"/var/www/shell.php";'
. 's:8:"contents";s:16:"<?php eval($_GET);";}';
// Somewhere a plugin does this on attacker-reachable data:
$value = unserialize( $payload ); // __wakeup() runs. The file is written. Read what the attacker actually controls. They do not upload code. Instead they reuse a class already loaded in WordPress or a plugin, then steer its properties through the serialized bytes so its own __wakeup() does the damage. The PHP unserialize() manual warns in plain text that you should never pass untrusted input to it. That warning is the whole security model in one line.
The POP gadget attack path: from attacker meta to RCE-adjacent#
A single __wakeup() is rarely the full exploit. More often the attacker chains several objects so one method feeds the next. This is a property-oriented programming gadget chain, and it turns harmless-looking classes into a path to a dangerous sink. The flow below traces one chain from an untrusted postmeta row to a filesystem or code-execution outcome.
Trace the two lanes side by side. On the left, unserialize() on attacker data instantiates a class and the gadget chain reaches a sink. On the right, json_decode() returns only plain arrays and stdClass values, so no magic method ever fires. Therefore the rule writes itself: use serialize() for your own trusted data, and JSON for anything a user or a remote system can influence.
What is_serialized() and maybe_unserialize() protect, and do not#
To connect this to a real architecture, the danger scales with how much untrusted structured data you let into meta tables. When you are building custom WordPress workflows without extra plugins, keep any externally sourced payload in a JSON column and decode it with json_decode(). That single habit removes the object-injection surface from your own code entirely.
When to stop using serialized data: the exit decision#
Serialization earns its place for trusted, whole-value storage. It stops earning its place the moment you need to query inside the value. So the final question is not philosophical, it is operational. Because a serialized blob is opaque to SQL, some access patterns outgrow it. This section gives the exit rubric that competitor pages never provide.
The un-queryability ceiling: you cannot WHERE or ORDER BY inside a blob#
A serialized value is one text field to the database. Therefore MySQL cannot index or filter on a key buried inside it. When you ask WP_Query to filter on serialized meta, it falls back to a full-value LIKE scan across the column. Consequently the query reads and pattern-matches every candidate row, which is slow and fragile. Moreover a LIKE against serialized text can match the wrong substring, because it cannot see structure.
This is the same ceiling that drives WordPress performance work at scale. Autoloaded options bloat because every serialized alloption value loads on every request. Meanwhile meta_query filters degrade because the blob cannot be indexed. For the query-cost math behind this, see our companion guide on deciding when postmeta itself becomes the bottleneck, which computes the break-even directly.
Stay serialized vs MySQL JSON column vs custom table vs taxonomy#
Once you hit the ceiling, the right move depends on the access pattern, not on taste. The rubric below scores four storage choices against the properties that actually decide it. Read each column against your own workload, then pick the leftmost option that still clears your bar. In short, climb only as far right as your query and scale needs force you.
| Property | Stay serialized | MySQL 5.7+ JSON column | Custom table | Taxonomy |
|---|---|---|---|---|
| Queryable by inner key? | Stay serializedNo, opaque to SQL | MySQL 5.7+ JSON columnYes, via JSON path expressions | Custom tableYes, plain columns | TaxonomyYes, term relationships |
| Indexable? | Stay serializedNo | MySQL 5.7+ JSON columnYes, on stored generated columns | Custom tableYes, native indexes | TaxonomyYes, term taxonomy indexes |
| Native type fidelity? | Stay serializedFull, every PHP type | MySQL 5.7+ JSON columnJSON types only, no PHP objects | Custom tableColumn types you define | TaxonomyStrings and IDs only |
| Migration safe? | Stay serializedAlready the default | MySQL 5.7+ JSON columnAdd a column, backfill, keep old rows | Custom tableNew table plus a sync path | TaxonomyTerms plus relationship rows |
| Scale ceiling | Stay serializedLow for filtered reads | MySQL 5.7+ JSON columnMedium, good for selective JSON keys | Custom tableHigh, purpose-built | TaxonomyHigh for shared vocabularies |
Read the rubric as an escalation. First a JSON column buys queryability while staying inside one row, and a stored generated column over a JSON path gives you an index. Next a custom table wins when several keys drive filtered reports, exactly the postmeta-versus-custom-table call. Finally a taxonomy fits shared vocabularies you filter and count across many posts. The MySQL JSON data type reference details the generated-column indexing that makes the middle option viable.
Why WordPress still uses serialization and not JSON, in one paragraph#
Pull the threads together. WordPress still uses serialization and not JSON because serialize() preserves every native PHP type and because two decades of stored data froze the format in place. However that same grammar is fragile at the byte level and dangerous on untrusted input. So the practical skill is not memorizing trivia. It is reading the length prefixes, repairing them with serialization-aware tooling, and refusing to unserialize anything an attacker can reach.
The exit is equally concrete. Stay serialized while you store trusted whole values, and move to a JSON column, a custom table, or a taxonomy the moment you must query inside the data. If you are shaping a WordPress backend for that kind of scale, the same discipline shows up when you are structuring a WordPress codebase for scale, and when you are comparing WordPress's architecture to Laravel's. Decode the bytes first, then decide.