Home Blog

The Hook That Fires Twice: Why save_post Isn’t as Simple as It Looks

The Problem A developer hooks save_post to sync a post to an external service on every save. The code works in testing — one save, one sync call. In production, the same…

The Problem

A developer hooks save_post to sync a post to an external service on every save. The code works in testing — one save, one sync call. In production, the same save triggers the sync twice, sometimes three times, and occasionally the site hangs entirely on save. Nothing in the code changed between testing and production; what changed is that save_post fires more often than most people assume, and one very common pattern inside its handler — calling wp_update_post() from within it — creates a save loop that keeps calling itself.

Why This Is Dangerous

save_post fires on every post write that goes through wp_insert_post(), including autosaves, revisions, and any inline quick-edit — not just the deliberate “Publish” click a developer pictures when writing the handler. A handler that isn’t guarded for autosaves does its expensive work (external API calls, cache rebuilds) far more often than intended, silently multiplying load. Worse, calling wp_update_post() inside a save_post handler without removing the action first re-triggers save_post again from inside itself — on a real project this has caused an infinite loop that only stopped when PHP’s execution time limit killed the request, taking the save with it.

Step-by-Step Solution — The Contract of Each Hook

`save_post` (action) — guard against autosaves, revisions, and yourself

Plaintext
add_action( 'save_post', function( $post_id, $post, $update ) {
	// Autosaves and revisions also fire save_post — skip both.
	if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
		return;
	}
	if ( 'post' !== $post->post_type ) {
		return;
	}

	// Remove our own action before calling wp_update_post(),
	// or this handler re-triggers itself and loops.
	remove_action( 'save_post', __FUNCTION__ );
	wp_update_post( array( 'ID' => $post_id, 'post_title' => forwp_normalize_title( $post->post_title ) ) );
	add_action( 'save_post', __FUNCTION__, 10, 3 );
}, 10, 3 );

The remove_action/add_action pair around any internal wp_update_post() call is the single most important line in this whole category — skip it, and any update from inside the handler re-fires the same handler.

`wp_insert_post` (action) — the broader event underneath save_post

Plaintext
add_action( 'wp_insert_post', function( $post_id, $post, $update ) {
	if ( $update ) {
		forwp_log_post_updated( $post_id );
	} else {
		forwp_log_post_created( $post_id );
	}
}, 10, 3 );

wp_insert_post fires for the same reasons save_post does, plus programmatic inserts that don’t go through the post editor at all — an import script, a REST API create, a scheduled task. If your logic needs to run regardless of how the post was created, this is the hook, not save_post.

`transition_post_status` (action) — logic that depends on the status change itself

Plaintext
add_action( 'transition_post_status', function( $new_status, $old_status, $post ) {
	if ( 'publish' === $new_status && 'publish' !== $old_status ) {
		forwp_send_publish_notification( $post->ID );
	}
}, 10, 3 );

save_post fires on every save regardless of status; transition_post_status is the correct hook specifically for “this post just became published” (or unpublished, or moved to draft) — without it, a developer usually ends up manually comparing $post->post_status against a cached previous value inside save_post, which is fragile and easy to get wrong.

`before_delete_post` (action) — cleanup before the record is gone for good

Plaintext
add_action( 'before_delete_post', function( $post_id, $post ) {
	forwp_delete_external_record( get_post_meta( $post_id, 'forwp_external_id', true ) );
}, 10, 2 );

This fires on permanent deletion (bypassing trash, or emptying it), while the post’s data — including its meta — is still readable. Waiting until after the fact means the meta you needed to clean up an external reference is already gone.

When It’s Better to Bring In a Specialist

The remove_action/add_action guard, the autosave/revision check, and the distinction between save_post and wp_insert_post are exactly the kind of detail that separates a working demo from a handler that survives production traffic — and exactly the kind of thing worth putting on a WordPress developer skills checklist if you’re evaluating a candidate or preparing for a technical interview yourself. A developer who has actually debugged a save-loop incident in production explains it in one sentence; one who hasn’t usually reaches for wp_update_post() inside save_post without a second thought.

FAQ

Most commonly because WordPress also fires save_post for the autosave that happens shortly before or after a manual save, and the handler has no guard against wp_is_post_autosave(). Add that check first, before any other logic.

Only if you remove the action before the call and re-add it after — otherwise wp_update_post() triggers save_post again, and if that handler unconditionally calls wp_update_post() too, the result is an infinite loop.

save_post fires specifically for posts saved through the standard editor flow (and its autosaves/revisions). wp_insert_post fires for the same cases plus any programmatic insert — an import, a cron job, a REST API request — so it’s the more reliable hook when you need to catch every possible way a post gets created.

Because transition_post_status hands you both the old and new status directly, with no need to cache or compare values yourself. Reimplementing that comparison inside save_post is a common source of subtle bugs when a post is saved multiple times in quick succession.

No — moving to trash uses wp_trash_post and its own hooks. before_delete_post fires specifically on permanent deletion: bypassing the trash, or emptying it.

Whether they instinctively guard save_post against autosaves and revisions, whether they know to remove their own action before an internal wp_update_post() call, and whether they can explain the difference between save_post and wp_insert_post without hesitating — all three come up constantly in real production code, and all three are cheap to verify in an interview.

Summary

A full breakdown of each of the 4 hooks in this category — with examples and a hands-on IDE — is on the Save & CRUD Hooks page, and the whole set is also available as one PDF to keep.