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', '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.

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, '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:

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

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Use Multiple SSH Keys with ~/.ssh/config

Run separate SSH keys for work, personal, and GitHub by binding each to its host in ~/.ssh/config with IdentityFile and IdentitiesOnly, so the right key is always offered.

Using AI to Update ACF Fields and WordPress Content

AI plus WP-CLI plus ACF is the canonical pattern for bulk content updates that used to take a careful afternoon. Schema-aware update_field calls, content rewrites at scale, image alt backfills, and the safety patterns that prevent disasters.