TechEarl

Useful Things You Can Do with acf/save_post

acf/save_post is the hook that fires after ACF saves a post's custom fields. Useful patterns: derived field computation, taxonomy sync, search-index refresh, ACF-to-meta mirroring, validation, audit logging. Plus the gotchas.

Ishan Karunaratne⏱️ 5 min readUpdated
Share thisCopied
Useful acf/save_post patterns: derived fields, taxonomy sync, search-index refresh, ACF-to-meta mirroring, validation, audit logging, with code examples.

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

php
add_action( 'acf/save_post', 'te_save_handler', 20 );

function te_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.

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

php
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:

php
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, 'te_reindex_post', [ (int) $post_id ] );
}, 20 );

add_action( 'te_reindex_post', function ( $post_id ) {
    // Push the post to the search index
    te_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:

php
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:

php
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:

php
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:

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

TagsWordPressACFHooksPHP

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Software Systems Architect · Senior Software Engineer · Engineering Leadership

Software systems architect and senior software engineer with more than two decades designing, building, and running production software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Now a CTO, though what I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts