Skip to content
ThemesIonic — home
WordPress Tutorials

Changing Post Data Before WordPress Writes It

The filter runs before the insert, which is what makes it the right place to force a status or rewrite a title without a second save. It also hands you slashed data, and a string operation that ignores that corrupts every apostrophe.

Updated 4 min read advanced

This is the hook that changes the post before the database sees it. wp_insert_post_data filters the array of columns wp_insert_post() is about to write, for both inserts and updates, and whatever you return is what gets written. That makes it the right place to force a field — a status, a default title, a sanitised slug — in one write, where save_post would need a second wp_update_post() and a guard against recursion.

The arguments

add_filter( 'wp_insert_post_data', 'ti_force_pending_for_contributors', 10, 4 );

function ti_force_pending_for_contributors( $data, $postarr, $unsanitized_postarr, $update ) {
	if ( 'post' !== $data['post_type'] ) {
		return $data;
	}

	if ( 'publish' === $data['post_status'] && ! current_user_can( 'publish_posts' ) ) {
		$data['post_status'] = 'pending';
	}

	return $data;
}
Argument What it is
$data Sanitised, slashed array of columns about to be written
$postarr Sanitised array of everything passed in, including meta and tax input not in $data
$unsanitized_postarr The raw input before sanitisation
$update true for an update, false for a new post

Register with four accepted arguments; the common two-argument version loses $update, which is the only reliable way to tell a first save from a later one. The example enforces the publish capability, which is how roles are meant to be applied: by capability, not by role name.

Slashing

The data is slashed — It's arrives as It\'s — because WordPress slashes before writing and the database layer unslashes. A callback that appends to the title and returns it is fine, because the appended text had no quotes to begin with. A callback that runs ucwords() or a regex on the title, or compares it to a string containing an apostrophe, is operating on backslashes. The safe shape:

$title = wp_unslash( $data['post_title'] );
$title = trim( preg_replace( '/\s+/', ' ', $title ) );
$data['post_title'] = wp_slash( $title );

Skipping the wp_slash() on the way back is the inverse bug: the next write unslashes once, and quotes that were escaped are now stored with a literal backslash. Both failures look like a corrupted title and neither errors.

Guards the hook needs

The filter fires for every post type, including the ones you never think of as posts: revisions, autosaves, navigation menu items, reusable blocks, and every custom type on the site. Attachments are the exception — they go through wp_insert_attachment_data with the same four arguments. A callback that forces a status without checking post_type forces it on menu items — which are posts of type nav_menu_item — and the menu stops rendering.

if ( 'revision' === $data['post_type'] ) {
	return $data;
}

Revisions copy the parent's data and are written through the same function; changing their data changes what the editor shows in the revision browser. Autosaves are revisions too and are caught by the same check — with one exception worth knowing: when the author of a draft autosaves it, the block editor updates the draft itself, with $update true and the real post type, and that is a genuine save your callback should treat as one.

What it cannot do

The post ID does not exist yet on a new post, so there is nothing to attach meta to and nothing to query. Taxonomy terms are in $postarr['tax_input'] but are assigned after the write, by a different code path; changing them here has no effect. Anything that needs the saved post — meta, terms, a notification with a permalink — belongs on save_post, which runs after the write with the ID in hand. A workflow often needs both: this filter to shape the row, save_post to act on it.

Returning something other than an array, or an array with a column missing, breaks the insert. Return $data on every path you did not mean to change.

The slug and date cases

Forcing post_name here is how you set a slug the editor cannot change — and it is written exactly as you set it, because WordPress made the slug unique before this filter ran and does not check again. Two posts forced to the same slug both get it, and one of them becomes unreachable. Run the value through wp_unique_post_slug( $slug, (int) $postarr['ID'], $data['post_status'], $data['post_type'], $data['post_parent'] ) inside the callback, or shape slugs through the wp_unique_post_slug filter instead.

Setting post_date for a scheduled post also requires post_date_gmt and a future status, because the GMT computation and the publish-to-future promotion both happened before this filter ran and will not run again. Setting one and not the other publishes at the wrong hour — the same timezone trap behind many cron reports about posts going live late.

Verify

  • Save a post as the restricted role: the status is what you forced, in one save, with no second revision created.
  • Save a title containing an apostrophe: it reads back exactly as typed.
  • Edit a navigation menu and save it: the menu still renders, confirming the post-type guard.
  • Open the revisions browser: revisions show their own titles, not ones rewritten by the filter.
  • Save through the block editor and through a REST request: both pass through the filter, and both should behave the same.

Frequently asked

When the change is to the post row itself — title, status, content, slug, date. Here it is written with the insert. On save_post the post already exists and changing it means calling wp_update_post, which runs the whole save again. For meta, taxonomy or anything outside the row, save_post is the right hook.
The data array is slashed, as WordPress expects before a database write. A string operation on it and then returning it as-is doubles the slashes on the next write. Unslash with wp_unslash, operate, then re-slash with wp_slash.
Yes. Every path that reaches wp_insert_post runs it, including REST, the block editor, XML-RPC and programmatic inserts. It also fires for revisions and autosaves, which is why the post type check matters.

Related guides