Skip to content

wp_update_post() eats your backslashes

Two WP-CLI scripts rewrote 78 posts on this site. 69 came back exactly as planned. The other 9 lost every literal backslash they had, including the string interpolation in two Swift tutorials, and nothing reported an error. wp_update_post() expects slashed input and unslashes whatever it is given.

On 2026-09-09 I ran two content scripts against this site through wp eval-file. One rewrote http:// image URLs to https:// in 73 posts from 2008 to 2011. The other removed [sociallocker] shortcode wrappers from 5 posts so the plugin could go. Both computed the new content in PHP, called wp_update_post() once per post, and finished without a single WP_Error.

Nine posts came out wrong. Every literal backslash in them was gone:

before   "username=\(username)&password=\(password)"
after    "username=(username)&password=(password)"
 
before   Classes\DKViewControl.h
after    ClassesDKViewControl.h
 
before   apps \ metacity \ keybinding_commands
after    apps  metacity  keybinding_commands
 
before   bind \\ quit
after    bind \ quit

The first pair is Swift string interpolation from the 2014 and 2015 login tutorials, which are among the most read pages here. Without the backslashes, the code posts the literal text (username) to the server. The second is an Xcode project path from the 2012 Objective-C version. The third is a gconf key from a 2009 Ubuntu post, and the last is a .screenrc binding from a 2010 one.

What wp_update_post() does with your array

wp_update_post() and wp_insert_post() were written for form submissions. WordPress slashes $_POST and $_GET on every request in wp_magic_quotes(), so every function downstream of a form assumes its input already carries one level of backslash escaping, and removes it right before the database write. Trimmed from wp-includes/post.php in WordPress 7.1.2:

function wp_update_post( $postarr = array(), $wp_error = false, $fire_after_hooks = true ) {
	if ( is_object( $postarr ) ) {
		// Non-escaped post was passed.
		$postarr = get_object_vars( $postarr );
		$postarr = wp_slash( $postarr );
	}
 
	$post = get_post( $postarr['ID'], ARRAY_A );
 
	// Escape data pulled from DB.
	$post = wp_slash( $post );
 
	$postarr = array_merge( $post, $postarr );
	// ...
	return wp_insert_post( $postarr, $wp_error, $fire_after_hooks );
}
 
// wp_insert_post(), just before $wpdb->update():
$data = wp_unslash( $data );

Read the two comments. Core slashes the row it reads from the database, because that data is not escaped. Core slashes a WP_Post object you pass in, because that is “non-escaped” too. An array is the one input it trusts to be already slashed, since an array is what $_POST looks like. Then everything, your fields included, goes through wp_unslash(), which is stripslashes_deep(): \\ becomes \, \( becomes (, \E becomes E.

So the fix is one function call:

wp_update_post( array( 'ID' => $id, 'post_content' => wp_slash( $content ) ), true );

wp_slash() is addslashes() applied recursively and wp_unslash() undoes exactly that, so the pair is lossless. To see the behaviour on any install, as a draft you can delete afterwards:

$id = wp_insert_post( array(
	'post_title'   => 'slash test',
	'post_content' => 'C:\Users\dk',
	'post_status'  => 'draft',
) );
echo get_post( $id )->post_content;   // C:Usersdk
 
wp_update_post( array( 'ID' => $id, 'post_content' => wp_slash( 'C:\Users\dk' ) ) );
echo get_post( $id )->post_content;   // C:\Users\dk

WP-CLI’s own commands already do this. wp post update 123 body.html runs its arguments through wp_slash() before calling core (Post_Command.php in entity-command), which is why publishing a post from a file works. The trap is code you write yourself: wp eval, wp eval-file, a plugin’s import routine, a migration script. Every “bulk update post content with WP-CLI” snippet I have seen passes the raw string.

Why 69 posts hid it

stripslashes() on a string with no backslash returns the string unchanged. Most prose has none. The 67 image-fix posts and 2 shortcode posts that came back byte-exact were the ones with nothing to lose, which is why a test run on a couple of sample posts proves nothing. The backslashes live in code blocks, Windows and gconf paths, regular expressions, LaTeX, JSON inside shortcode attributes, and escaped quotes. On a blog with 356 posts going back to 2008, that was nine of them.

The return value does not help either. Both scripts checked for WP_Error and got post IDs back. The write succeeded. It wrote the wrong bytes.

Two more filters that run when nobody is logged in

Under WP-CLI there is no current user unless you pass --user. That changes what wp_insert_post() does to your content in a second way. kses_init() installs the wp_filter_post_kses filters whenever current_user_can( 'unfiltered_html' ) is false, so fifteen-year-old markup gets sanitised as if an untrusted contributor had submitted it, and tags or attributes outside the allowed list disappear with no warning. balanceTags sits on content_save_pre as well, and rewrites tag nesting if the use_balanceTags option is on.

Neither is something a content script asked for. The scripts on this site now drop both before writing:

kses_remove_filters();
remove_filter( 'content_save_pre', 'balanceTags', 50 );
 
$res = wp_update_post(
	array( 'ID' => $id, 'post_content' => wp_slash( $after ) ),
	true
);

Running the script with --user=<an administrator> is the other way to get unfiltered_html. Removing the filters says what you want: the bytes you computed are the bytes that get stored.

Verify the bytes, not the return value

This is the check that turned silent corruption into six post IDs. After each write, read the post back and compare it to what you planned to write:

clean_post_cache( $id );
$written = get_post( $id )->post_content;
 
if ( $written !== $after ) {
	WP_CLI::warning( sprintf(
		'%d: stored content differs from the planned content (%d vs %d bytes)',
		$id, strlen( $written ), strlen( $after )
	) );
}

clean_post_cache() matters. Without it get_post() can hand back the object WordPress cached before the write. The image script had this check and listed six posts at the end of its apply run, with byte counts showing how much had gone. The shortcode script did not have it, and its three damaged posts sat on the live site for five hours before anyone looked.

A byte compare catches every transform you did not ask for, not only this one. kses, balanceTags, a plugin’s filter on content_save_pre, a character set problem: anything that changes the content between your variable and the database row shows up as a length mismatch.

Repairing it without trusting the damaged content

You cannot put the backslashes back by looking at the damaged text. (username) is valid Swift on its own, apps metacity could be a typo, and bind \ quit is a different .screenrc command. The original is needed, and the repair has to be computed from it.

The image script had written a JSON backup of every original before touching anything, so the repair recomputes the intended result from that backup, checks that the damage is exactly what this bug produces, and only then writes:

$want   = $plan( $original );            // the same transform the first run applied
$stored = get_post( $id )->post_content;
 
if ( $stored === $want ) {
	continue;                            // already right, nothing to do
}
 
if ( wp_unslash( $want ) !== $stored ) { // damage this bug does not explain
	WP_CLI::warning( "$id: inspect by hand" );
	continue;
}
 
wp_update_post( array( 'ID' => $id, 'post_content' => wp_slash( $want ) ), true );
clean_post_cache( $id );
if ( get_post( $id )->post_content !== $want ) {
	WP_CLI::error( "$id: still wrong after repair" );
}

The guard in the middle is the important line. If a post had been edited by hand in the meantime, or damaged by something else, wp_unslash( $want ) would not equal what is stored and the script refuses to touch it. Six posts matched, six were repaired, and a second run reports six already byte-exact.

When there is no backup

The shortcode script’s backup never made it to disk. It was meant to be written before the first database write; the write failed, and the posts were updated anyway. Five hours later there were three damaged posts and no originals.

Two things follow from that. A missing backup file is not evidence that a script never ran. It is evidence that the backup step failed, and the database has to be checked directly: post_modified, byte lengths against a known-good copy. And post revisions are no use for this bug. WordPress saves a revision of the new content on every update, so all five revisions created by that run were post-damage copies.

The originals came from the previous day’s mysqldump. Reading one post_content value back out of a dump means respecting its escaping, because the backslashes you are trying to recover are themselves escaped there as \\, and the quotes as \'. The repair then ran the same way as above: recompute the intended result from the original, refuse anything the bug does not explain, write slashed, read back, compare. Three posts, thirteen backslashes, checked against the live pages afterwards.

The order that makes all of this unnecessary: compute every change, write the backup, read the backup back, and only then touch the database. A script that cannot write its backup must stop before its first wp_update_post().

What every content script on this site does now

  1. wp_slash() on anything handed to wp_update_post() or wp_insert_post() as an array.
  2. kses_remove_filters() and no balanceTags, so the stored bytes are the computed bytes.
  3. Backup written and read back before the first write, and the run aborts if that fails.
  4. After each write, clean_post_cache(), re-read, and a byte compare against the planned content, with every mismatch listed at the end.
  5. Dry run by default, apply to write, and a second apply that finds nothing left to do.

Then the usual purges, because the page cache, the object cache and Cloudflare each still hold the old post. That is its own post.

Dipin Krishna

Written by Dipin Krishna

Senior full-stack engineer with 15 years across Django, Laravel, SwiftUI and the infrastructure underneath. Available for contract work.

Work with me →

Leave a note

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.