Skip to content
ThemesIonic — home
WordPress Tutorials

Running Code When a WordPress Post Is Saved

The hook fires on autosaves, on revisions, on bulk edits and on every post type. Left unguarded it runs several times for a single click, and code that updates the post from inside it runs forever.

4 min read advanced

One click on Update can fire this hook four times. save_post runs when a post is written to the database — and an autosave is a write, a revision is a write, and the post itself is a write. Add a bulk edit and the count goes up again. A callback that does real work without guards does that work repeatedly, on drafts nobody published, for post types it was never meant to touch.

The guards, in order

add_action( 'save_post_post', 'ti_on_save', 10, 3 );

function ti_on_save( $post_id, $post, $update ) {
	if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
		return;
	}

	if ( wp_is_post_revision( $post_id ) ) {
		return;
	}

	if ( ! current_user_can( 'edit_post', $post_id ) ) {
		return;
	}

	if ( 'publish' !== $post->post_status ) {
		return;
	}

	// Real work goes here.
}

Four checks, four distinct failures prevented:

  • The autosave guard stops the work running while someone is still typing. Without it, a half-written draft triggers whatever the callback does, repeatedly.
  • The revision guard stops it running against the revision row, whose ID is not the post ID. Code that writes meta without this guard writes it to a revision, where nothing will ever read it.
  • The capability check is not decoration. This hook can fire in contexts where the acting user is not the author, and a callback that changes data without checking is an authorisation hole.
  • The status check confines the work to published posts, if that is what was meant.

Using save_post_post rather than save_post restricts the hook to one post type at registration time, which is cheaper and clearer than testing $post->post_type inside the callback.

The infinite loop

Updating the post from inside the callback re-enters the hook:

// This never finishes.
wp_update_post( array( 'ID' => $post_id, 'post_title' => $new_title ) );

Remove the callback first, then restore it:

remove_action( 'save_post_post', 'ti_on_save', 10 );
wp_update_post( array( 'ID' => $post_id, 'post_title' => $new_title ) );
add_action( 'save_post_post', 'ti_on_save', 10, 3 );

The priority in remove_action must match the one used to add it. A mismatch leaves the callback attached, the loop intact, and a request that runs until PHP gives up — which surfaces as a white screen or a timeout rather than a clear error.

Writing post meta does not re-enter the hook, so update_post_meta needs none of this. The loop only applies to functions that save the post itself.

The block editor changed what arrives

The classic editor submitted a form, so $_POST held everything on screen and callbacks read from it directly. The block editor saves over the REST API, and there is no form submission. $_POST is empty, and a callback reading from it finds nothing — on a site where the same code worked for years under the classic editor.

Meta that has to survive a block editor save must be registered so the API knows about it, with a type, a sanitisation callback and an authorisation callback. Reading from $_POST is a classic-editor technique, and its absence is one of the differences people meet when they consider restoring the classic editor.

The third argument, $update, is true when the post already existed. It is the clean way to separate first publish from subsequent edits without querying anything.

Bulk edits and imports

A bulk edit fires the hook once per post, quickly. An import fires it once per imported item, which on a large CSV import can mean thousands of times in one request. Anything slow in the callback — a remote request, an image regeneration, a write to an external service — multiplies by that count and turns an import into a timeout.

If the work is expensive, queue it rather than doing it inline. Recording that a post needs processing is fast; doing the processing on a scheduled run is where it belongs, alongside the considerations in disabling WP-Cron.

Verify by counting

Add a temporary line that writes to the error log with the post ID and status, enable logging through debug mode, and save a post once. The log should show one entry, not four.

Then test the awkward paths: save a draft, publish it, edit and update it, and bulk edit it alongside another post. Each should produce exactly the behaviour you intended, and the draft save should produce none at all if the status guard is in place.

Frequently asked

Because autosave, the revision write and the real save each fire it. Guarding on DOING_AUTOSAVE and on wp_is_post_revision reduces that to the one save you care about.
Because wp_update_post fires save_post again, which calls your callback, which updates the post. Unhook the callback before updating and hook it back afterwards.
Because the editor saves over the REST API, so there is no classic form submission. Meta that needs to arrive with the save has to be registered for REST rather than read from POST.

Related guides