do_action( ‘save_post’, int $post_id, WP_Post $post, bool $update )

Fires once a post has been saved.


Post ID.
Post object.
Whether this is an existing post being updated.

More Information

save_post is an action triggered whenever a post or page is created or updated, which could be from an import, post/page edit form, xmlrpc, or post by email. The data for the post is stored in $_POST, $_GET or the global $post_data, depending on how the post was edited. For example, quick edits use $_POST.

Since this action is triggered right after the post has been saved, you can easily access this post object by using get_post($post_id);.

NOTE: As of WP 3.7, an alternative action has been introduced, which is called for specific post types: save_post_{post_type}. Hooking to this action prevents your callback to be unnecessarily triggered.

Avoiding infinite loops

If you are calling a function such as wp_update_post that includes the save_post hook, your hooked function will create an infinite loop. To avoid this, unhook your function before calling the function you need, then re-hook it afterward.

  * Makes all posts in the default category private.
  * @see 'save_post'
  * @param int $post_id The post being saved.
function set_private_categories( $post_id ) {
    // If this is a revision, get real post ID.
    $parent_id = wp_is_post_revision( $post_id );

    if ( false !== $parent_id ) {
        $post_id = $parent_id;

    // Get default category ID from options.
    $defaultcat = get_option( 'default_category' );

    // Check if this post is in default category.
    if ( in_category( $defaultcat, $post_id ) ) {
        // unhook this function so it doesn't loop infinitely
        remove_action( 'save_post', 'set_private_categories' );

        // update the post, which calls save_post again.
        wp_update_post( array( 'ID' => $post_id, 'post_status' => 'private' ) );

        // re-hook this function.
        add_action( 'save_post', 'set_private_categories' );
add_action( 'save_post', 'set_private_categories' );


do_action( 'save_post', $post_id, $post, $update );



User Contributed Notes

  1. Skip to note 11 content

    When using WordPress 3.7 or later, it’s a good idea to use the save_post_{$post->post_type} hook when it makes sense to in order to reduce code and fire less hooks overall when posts are created and updated.

    Documentation can be found here:

  2. Skip to note 12 content

    Force a new post of have specific category term,

    add_action( 'save_post', 'set_post_default_category', 10,3 );
    function set_post_default_category( $post_id, $post, $update ) {
    	// Only want to set if this is a new post!
    	if ( $update ){
    	// Only set for post_type = post!
    	if ( 'post' !== $post->post_type ) {
    	// Get the default term using the slug, its more portable!
    	$term = get_term_by( 'slug', 'my-custom-term', 'category' );
    	wp_set_post_terms( $post_id, $term->term_id, 'category', true );
  3. Skip to note 13 content

    Below is a basic example that will send an email every time a post or page is updated on your website.

    function my_project_updated_send_email( $post_id ) {
    	// If this is just a revision, don't send the email.
    	if ( wp_is_post_revision( $post_id ) ) {
    	$post_title = get_the_title( $post_id );
    	$post_url = get_permalink( $post_id );
    	$subject = 'A post has been updated';
    	$message = "A post has been updated on your website:\n\n";
    	$message .= $post_title . ": " . $post_url;
    	// Send email to admin.
    	wp_mail( '', $subject, $message );
    add_action( 'save_post', 'my_project_updated_send_email' );
  4. Skip to note 14 content

    The save_post_{post_type} hook fires BEFORE the general save_post hook, meaning that save_post will override any meta updates made with save_post_{post_type}. Many plugins like ACF and Pods use the save post action hook, so if you are trying to update a meta field and you are using one of these plugins, you must use the save_post hook instead.

    // does not work
    function my_save_meta_function( $post_id, $post, $update )
    	// fires but can be overridden by plugins, regardless of priority number
    	update_post_meta( $post_id, 'address', '123 Test St' );
    add_action( 'save_post_event', 'my_save_meta_function', 99, 3 );
    // does work
    function my_save_meta_function( $post_id, $post, $update )
    	if ( get_post_type( $post_id ) !== 'event' ) return;
    	update_post_meta( $post_id, 'address', '123 Test St' );
    add_action( 'save_post', 'my_save_meta_function', 99, 3 );
  5. Skip to note 15 content

    The documentation provides a way to avoid an infinite loop by removing the hook and then adding it again after we have used a function such as `wp_update_post`.

    The problem about this is that other actions on the same hook may still trigger twice.

    I came up with the following alternative:

     * Prevent infinite loop.
    //remove_action( 'save_post', array( self::class, 'save_meta_boxes' ) );
    global $wp_actions, $wp_filters, $wp_filter;
    $actions = $wp_actions;
    $filters = $wp_filters;
    $filter  = $wp_filter;
    remove_all_actions( 'save_post' );
    wp_update_post( $post );
    $wp_actions = $actions;
    $wp_filters = $filters;
    $wp_filter  = $filter;
    //add_action( 'save_post', array( self::class, 'save_meta_boxes' ) );

    By doing this we take a “snapshot” of all the registered hooks before we update the post. Then we remove all the actions for the hook, and once it has been updated, we restore the snapshots.

    You should also think about removing all `save_post_{$post->post-type}` actions and viceversa.

    It would be nice if there was a core function for this. `wp_update_post` already has a third optional parameter to prevent firing the after insert hooks. A fourth parameter could be added to prevent firing the `save_post` and `save_post_{$post->post-type}` hooks so that we don’t have to use workarounds to prevent the infinite loop issue.

  6. Skip to note 16 content

    To trigger for specific post type, assume we have a post type name ‘book’

    function wpdocs_book_meta( $post_id ) {
    	// Check the logged in user has permission to edit this post
    	if ( ! current_user_can( 'manage_options' ) ) {
    		return $post_id;
    	if ( isset( $_POST['website'] ) ) {
    		$website = esc_url_raw( $_POST['website'] );
    		update_post_meta( $post_id, 'website', $website );
    add_action( 'save_post_book', 'wpdocs_book_meta' );
  7. Skip to note 17 content

    The $post_ID passed to the action is the ID of the revision while updating a post. To find the ID of the parent post, use wp_get_post_parent_id.

    function my_function_on_save_post( $post_id ) {
    	// Find parent post_id.
    	if ( $post_parent_id = wp_get_post_parent_id( $post_id ) ) {
    		$post_id = $post_parent_id;
    	// Do something.
    add_action( 'save_post', 'my_function_on_save_post' );
  8. Skip to note 18 content

    I was trying to add a hook to review the current posts and terms in the database whenever a post was updated (through the regular editing interface, not direct calls to the API) but hit a problem.

    If a user updates only a post’s Terms (categories, tags), save_post is triggered before the new Terms are pushed to the database. (If the Term changes are saved with any other changes, the Terms are written to the DB before save_post is triggered.)


    wp_insert_post() does look like it saves Terms before triggering the save_post action but the Term data it passes to (ultimately) wp_set_object_terms() is the OLD terms for the post. (Why resave the old data? Dunno.)

    After wp_insert_post() triggers save_post, an additional call to wp_set_object_terms() is made (from WP_REST_Posts_Controller) that has a the new Terms data in it. (I didn’t dig further to find out why the two calls for this case.)


    If your hook needs the Terms to be accurate, attach it to rest_after_insert_(post|page|attachment) instead. This is triggered when ALL changes are stored, regardless of whether it is just the post content, just the terms, or both.

  9. Skip to note 19 content

    Here is my updated code which will check whether post title is exist or not before inserting into news post title.

    function wpdocs_save_post_callback( $post_id ) {
        $post = get_post( $post_id );
        if ( $post && 'post' === $post->post_type && isset( $_POST['post_title'] ) ) {
            $post_title = sanitize_text_field( $_POST['post_title'] );
            // Check if a post with the same title already exists in the "news" post type
            $existing_post = get_page_by_title( $post_title, OBJECT, 'news' );
            if ( ! $existing_post ) { // If no post with the same title exists
                $post_data = array(
                    'post_type'   => 'news',
                    'post_status' => 'publish',
                    'post_title'  => $post_title
                wp_insert_post( $post_data );
            } else {
                // Post with the same title already exists
                // You can handle this case according to your requirement, e.g., display a message or update the existing post
    add_action( 'save_post', 'wpdocs_save_post_callback' );
  10. Skip to note 20 content

    Here is a simple example which will save post on publish and also at the same time add the post in another post type also.

    function wpdocs_save_post_callback( $post_id ) {
    	$post = get_post( $post_id );
          	if ( $post && 'post' === $post->post_type && isset( $_POST['post_title'] ) ) {
    		$post_title = sanitize_text_field( $_POST['post_title'] );
    		$post_data = array(
    			'post_type' => 'news',
    			'post_status' => 'publish',
    			'post_title' => $post_title
    		wp_insert_post( $post_data );
    add_action( 'save_post', 'wpdocs_save_post_callback' );

You must log in before being able to contribute a note or feedback.