acf/save_post fires right after ACF saves a post's custom field values. It is the canonical hook for any logic that needs to run in response to ACF field changes: computing derived fields, syncing data to taxonomy terms, refreshing a search index, mirroring ACF data into standard WordPress meta, validating field combinations, audit logging. The directory sites I run lean heavily on this hook for keeping derived data in sync across thousands of posts.
Jump to:
- The hook signature and priorities
- Pattern 1: Computing derived fields
- Pattern 2: Syncing ACF values to taxonomy terms
- Pattern 3: Triggering a search-index refresh
- Pattern 4: ACF-to-postmeta mirroring for query performance
- Pattern 5: Cross-field validation
- Pattern 6: Audit logging
- Gotchas
The hook signature and priorities
add_action( 'acf/save_post', 'my_save_handler', 20 );
function my_save_handler( $post_id ) {
// $post_id is the post ID, or "option" for options pages,
// or "user_42", "term_5", etc. depending on context.
// Bail on autosaves and revisions
if ( wp_is_post_autosave( $post_id ) ) return;
if ( wp_is_post_revision( $post_id ) ) return;
// Optional: bail on specific post types
if ( get_post_type( $post_id ) !== 'listing' ) return;
// Read the just-saved values
$price = get_field( 'price', $post_id );
// ... do work
}The priority argument matters. Priority 10 (the default) fires BEFORE ACF has saved the meta. Priority 20+ fires AFTER, which is what you want in 99% of cases. Always use priority => 20 so get_field() inside the callback returns the new values, not the old ones.
acf/save_post fires for posts, options pages, user profile updates, taxonomy term edits, and comment metadata edits. The $post_id argument tells you which context. Check is_numeric( $post_id ) or strpos( $post_id, 'option' ) === 0 to branch on context.
Pattern 1: Computing derived fields
The classic use case. You have two ACF fields that together imply a third value, and you want the third value stored so queries can use it directly.
add_action( 'acf/save_post', function ( $post_id ) {
if ( get_post_type( $post_id ) !== 'product' ) return;
$price = (float) get_field( 'price', $post_id );
$discount_pct = (float) get_field( 'discount_pct', $post_id );
if ( $price > 0 && $discount_pct > 0 ) {
$final = $price * ( 1 - ( $discount_pct / 100 ) );
update_field( 'final_price', $final, $post_id );
}
}, 20 );Why store the derived value rather than compute on read? Performance. If final_price is what WP_Query sorts and filters by, it has to be a stored meta value. Computing on read works for display; it does not work for queries.
Pattern 2: Syncing ACF values to taxonomy terms
Common on directory sites: an ACF field stores a single value that should also be reflected as a taxonomy assignment. The taxonomy can be queried efficiently by WP_Query; the ACF value cannot.
add_action( 'acf/save_post', function ( $post_id ) {
if ( get_post_type( $post_id ) !== 'listing' ) return;
$state = get_field( 'address_state', $post_id );
if ( empty( $state ) ) return;
// Find or create the term in the "location" taxonomy
$term = term_exists( $state, 'location' );
if ( ! $term ) {
$term = wp_insert_term( $state, 'location' );
}
if ( ! is_wp_error( $term ) ) {
wp_set_post_terms( $post_id, [ (int) $term['term_id'] ], 'location', false );
}
}, 20 );The user edits the ACF field; the taxonomy stays in sync. Queries that filter by state can now use tax_query instead of slower meta_query.
Pattern 3: Triggering a search-index refresh
If you maintain an Elasticsearch or Algolia index of your posts, acf/save_post is the right place to enqueue a re-index of the changed post:
add_action( 'acf/save_post', function ( $post_id ) {
if ( ! is_numeric( $post_id ) ) return; // options/users don't get indexed
if ( get_post_status( $post_id ) !== 'publish' ) return;
// Schedule the re-index via WP-Cron rather than running synchronously
wp_schedule_single_event( time() + 10, 'my_reindex_post', [ (int) $post_id ] );
}, 20 );
add_action( 'my_reindex_post', function ( $post_id ) {
// Push the post to the search index
my_search_indexer()->index_post( $post_id );
} );Scheduling the re-index for a few seconds later means the editor saves the post and gets a fast response, while the indexer runs asynchronously via WP-Cron.
Pattern 4: ACF-to-postmeta mirroring for query performance
For ACF fields you query against frequently with meta_query, mirroring the value into a standard _meta key with an explicit type can be much faster:
add_action( 'acf/save_post', function ( $post_id ) {
if ( get_post_type( $post_id ) !== 'listing' ) return;
$price = (float) get_field( 'price', $post_id );
// Store as a numeric meta key for fast NUMERIC compare queries
update_post_meta( $post_id, '_price_numeric', $price );
}, 20 );Then your queries hit _price_numeric with a NUMERIC compare, which the database handles much more efficiently than ACF's default storage. The cost is the extra meta row per post; the benefit is queries that scale.
Pattern 5: Cross-field validation
Sometimes a save should be partially undone or flagged because two field values do not validate together:
add_action( 'acf/save_post', function ( $post_id ) {
if ( get_post_type( $post_id ) !== 'event' ) return;
$start = get_field( 'start_date', $post_id );
$end = get_field( 'end_date', $post_id );
if ( $start && $end && strtotime( $end ) < strtotime( $start ) ) {
// End date is before start; reset it to the start date and log
update_field( 'end_date', $start, $post_id );
error_log( "Reset end_date < start_date on post $post_id" );
}
}, 20 );This is corrective, not preventive. For preventive validation (refusing the save entirely), use acf/validate_value filters per field rather than acf/save_post.
Pattern 6: Audit logging
For directory sites with editorial oversight or compliance requirements, logging every ACF field change is straightforward:
add_action( 'acf/save_post', function ( $post_id ) {
if ( ! is_numeric( $post_id ) ) return;
$user = wp_get_current_user();
$changes = []; // could diff against old values from a pre-save hook
error_log( sprintf(
'[acf-audit] post=%d user=%s post_type=%s',
$post_id,
$user->user_login,
get_post_type( $post_id )
) );
}, 20 );For real audit logging, capture the before/after diff in an acf/save_post at priority 10 (before save, to read old values) and then at priority 20 (after save, to read new values) and store the diff in a custom audit table.
Gotchas
Recursive update_field calls. If your acf/save_post callback calls update_field(), that triggers another acf/save_post. Without a guard, this is an infinite loop. The fix:
add_action( 'acf/save_post', function ( $post_id ) {
static $running = [];
if ( isset( $running[ $post_id ] ) ) return;
$running[ $post_id ] = true;
update_field( 'derived_value', 'something', $post_id );
unset( $running[ $post_id ] );
}, 20 );Priority 10 vs 20. Always use 20 unless you specifically need the pre-save state. Priority 10 callbacks see the OLD values when reading via get_field().
Options pages and user updates fire too. Check $post_id context and bail on contexts you do not handle.
Bulk imports trigger the hook repeatedly. If you run a 10,000-post import script, your acf/save_post callback runs 10,000 times. Make sure it is fast or use remove_action to temporarily detach during bulk operations.
The hook does NOT fire when posts are updated via wp_insert_post or wp_update_post directly without ACF metadata. It only fires when ACF is actively saving its meta. For pure post-update workflows, use save_post instead.
For the broader pattern of programmatic ACF mutations from PHP scripts and WP-CLI, see How to Update ACF Fields Programmatically and Using AI with WP-CLI for Faster WordPress Operations.
Sources
Authoritative references this article was fact-checked against.
- acf/save_post action reference (Advanced Custom Fields docs)advancedcustomfields.com
- get_field() function reference (Advanced Custom Fields docs)advancedcustomfields.com





