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.…

Action Hook since 1.2.0
Fires right before a post is permanently deleted
before_delete_post is your last chance to act while the post (and its data) still exists — right after this, wp_delete_post() removes the row for good.
Inside wp_delete_post(), only for a hard/permanent delete — does not fire when a post is simply moved to Trash (that's wp_trash_post).
do_action( 'before_delete_post', int $post_id, WP_Post $post );$post_id int — ID of the post about to be deleted.$post WP_Post — The post object, still intact at this point.add_action( 'before_delete_post', function( $post_id, $post ) {
if ( 'book' !== $post->post_type ) {
return;
}
global $wpdb;
$wpdb->delete( $wpdb->prefix . 'book_ratings', [ 'post_id' => $post_id ] );
}, 10, 2 );Delete an associated custom table row before the post itself is gone.
add_action( 'before_delete_post', function( $post_id ) {
$file = get_post_meta( $post_id, 'attached_export_file', true );
if ( $file && file_exists( $file ) ) {
unlink( $file );
}
} );Remove a generated file tied to the post before the post record disappears.

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.…
No. Moving a post to Trash fires wp_trash_post instead. before_delete_post only fires for a permanent/hard delete — either from Trash or via force-delete.
Source: wp-includes/post.php
One session: this hook, then each related_hooks entry. Walk it and tell us what to change.
before_delete_post → save_post → wp_insert_post
Practice the before_delete_post WordPress action hook in our interactive sandbox — a VS Code-style editor with a step-by-step before_delete_post tutorial, add_action exercises, and instant feedback. No local WordPress install required.