TechEarl

Refresh Posts When a Taxonomy Term Changes (saved_term Hook)

Editing a taxonomy term does not refresh the posts that cache term-derived data. Hook saved_term, re-save the attached posts, and bust the stale output, carefully and at scale.

Ishan Karunaratne⏱️ 14 min readUpdated
Share thisCopied
How to refresh WordPress posts when a taxonomy term changes: hook the saved_term action, query the posts attached to the term, and re-save them with wp_update_post() to bust stale term-derived cache.

If editing a taxonomy term leaves your posts serving the old term data, the fix is to re-save the affected posts when the term is saved. WordPress does not do this for you: changing a category or a custom-taxonomy term updates the term row, but it does not touch any post that has baked that term's data into cached output. You hook the saved_term action, find the posts attached to the term, and re-save them so their post-level caches regenerate:

php
add_action( 'saved_term', 'te_refresh_posts_on_term_save', 10, 3 );

function te_refresh_posts_on_term_save( $term_id, $tt_id, $taxonomy ) {
    $posts = get_posts( array(
        'post_type'    => 'any',
        'post_status'  => 'publish',
        'numberposts'  => 1,
        'cache_results' => false,
        'tax_query'    => array(
            array(
                'taxonomy' => $taxonomy,
                'field'    => 'term_id',
                'terms'    => $term_id,
            ),
        ),
    ) );

    if ( ! empty( $posts ) ) {
        wp_update_post( $posts[0] );
    }
}

That is the surgical version: it re-saves one representative post per term edit, which is enough to bust a shared, per-term cache fragment without hammering the database. The rest of this article explains why it is needed, how saved_term works, why re-saving is what actually busts the cache, and the scale caveat that makes the naive "re-save every post" version a trap.

Why editing a term does not refresh the posts

WordPress treats a term and the posts attached to it as separate things with separate cache lifecycles. When you rename a category, fix a typo in a term description, or update a term's ACF meta, WordPress updates the term row and clears the term cache. It does not go looking for every post in that term to invalidate post-level caches, because most of the time it does not need to: a theme that reads the term name fresh on every request via get_the_category() will just pick up the new value.

The problem only appears when a post's output genuinely derives from term data and that output is cached. A few real cases I have hit:

  • The post markup bakes in a term's name or description, and that markup sits in a persistent object cache or a transient.
  • The post renders ACF or term meta attached to the term, and the rendered fragment is cached per post.
  • A full-page HTML cache or a CDN edge cache is serving a snapshot of the post that includes the old term text.
  • A search index (Elasticsearch, Algolia) stores a flattened copy of the post that includes its term names, and nothing re-indexes the post when only the term changed.

In all of these, the term is now correct everywhere except inside the post-shaped cache. Until each affected post is re-saved (or its cache otherwise expires), readers keep getting the stale value. Editing the term in wp-admin feels like it should have fixed everything, and it quietly did not.

The saved_term hook

saved_term is the clean place to react to a term being created or edited. WordPress 5.5 (August 2020) introduced it specifically as the "after the dust has settled" hook: it fires after the term has been written and after the term cache has been cleared, so by the time your callback runs you are looking at the new term state, not a half-updated one. Anything earlier in the term-save flow risks reading stale data or racing the core cache clear.

The action passes five arguments:

php
do_action( 'saved_term', $term_id, $tt_id, $taxonomy, $update, $args );
  • $term_id (int) the term's ID.
  • $tt_id (int) the term-taxonomy ID.
  • $taxonomy (string) the taxonomy slug, for example category or your custom taxonomy.
  • $update (bool) true if this was an edit, false if the term was just created.
  • $args (array) the arguments passed to the create/update call.

There is also a taxonomy-specific variant, saved_{$taxonomy} (for example saved_category), if you only care about one taxonomy and want to skip the slug check. Because saved_term arrived in 5.5, this whole technique needs WordPress 5.5 or newer. On anything older you would fall back to the older edited_term / created_term pair, which fire before the cache clear and are fiddlier to reason about.

In the callback above I take the first three arguments because that is all the query needs. If you want to skip newly-created terms (a brand-new term has no posts yet, so re-saving is pointless), add $update as a fourth argument and bail early when it is false.

If you arrived here having already tried save_post, that is the trap worth naming. When an editor changes only a post's terms (assigning categories, swapping tags) and nothing else, save_post can fire before the term relationships are written to the database. This is a well-known Gutenberg and REST-API gotcha: the post row saves on one request and the term assignments come through a separate wp/v2 taxonomy request, so a callback hooked to save_post reads the post with its old terms and misses the change entirely. saved_term is the correct hook precisely because it fires after the term has been saved and its cache cleared, so you are reacting to the event that actually changed (the term), not to a post save that may not even carry the new terms yet. For the REST-era equivalent on the post side, the hook that fires once a post and all of its meta and terms have finished saving over REST is rest_after_insert_{$post_type} (for example rest_after_insert_post); reach for that when your trigger genuinely is a post edit through the block editor, and keep saved_term for when the trigger is a term edit.

Re-saving the post is what busts the cache

The reason wp_update_post() is the right tool, rather than poking at the cache directly, is that re-saving a post runs the entire normal invalidation path. wp_update_post() fires save_post (and the post-type-specific save_post_{$post_type}), bumps the post's modified time, and triggers core's clean-up of the post cache. Anything that hooks save_post to rebuild a cached fragment, re-index the post, or purge a page cache runs again, exactly as if you had clicked Update in the editor.

That is the whole point: you do not need to know which cache holds the stale value. Object cache, transient, full-page cache plugin, CDN purge hook, search re-index, they almost all hang off save_post. Re-saving the post is the one action that fans out to all of them. Passing the post object you already fetched ($posts[0]) is enough; you do not need to mutate any field, the re-save itself is the trigger.

That fan-out cuts both ways, so mind the recursion risk. wp_update_post() re-fires save_post, which means if you also have save_post logic that can touch terms (for instance code that re-assigns a term and thereby triggers saved_term again), you can drive an infinite loop: term save calls the post re-save, the post re-save touches a term, the term touch calls the post re-save, and around it goes. Guard against it. The two standard fixes are to unhook your own callback for the duration of the update (remove_action() before the wp_update_post() call, add_action() after) or to set a static reentrancy flag the callback checks on entry and clears on exit. Either way, be aware that each wp_update_post() can also generate a post revision, so an unguarded loop spams the revisions table as well as burning queries.

If your stale data lives in a search index specifically, this is also how an ElasticPress-style integration picks up the change. I cover the query side of that in my notes on running WP_Query through ElasticPress; the indexing side hooks save_post, so the same re-save refreshes the indexed copy of the post's term names.

Do it at scale carefully

Here is the part that bites people, and the reason I default to re-saving a single post rather than all of them.

A popular term can be attached to thousands of posts. A "News" category on a busy site, a "WordPress" tag, a manufacturer term on a product catalog. If your callback queries all posts in the term and loops wp_update_post() over every one of them, then a single term edit in wp-admin becomes a synchronous re-save of thousands of posts. The admin request hangs, very likely hits the PHP max_execution_time and times out, and pounds the database with thousands of writes (each wp_update_post() is a real UPDATE plus all the save_post work) while the editor stares at a spinner. That is a self-inflicted outage triggered by someone fixing a typo.

So be honest about what you actually need:

  • A shared, per-term cache fragment (one cached block that shows the term name, reused across posts): re-saving a single representative post is enough to regenerate it. That is the snippet at the top.
  • Per-post output that each embeds the term (every post in the term has its own cached copy of the term text): you do genuinely need to touch every post, so do it asynchronously. Queue the post IDs and process them in the background via WP-Cron, Action Scheduler, or a WP-CLI loop, not inside the saved_term request.

For the batched approach, query IDs only and schedule the work:

php
add_action( 'saved_term', 'te_queue_posts_on_term_save', 10, 3 );

function te_queue_posts_on_term_save( $term_id, $tt_id, $taxonomy ) {
    $ids = get_posts( array(
        'post_type'    => 'any',
        'post_status'  => 'publish',
        'numberposts'  => 200,
        'fields'       => 'ids',
        'cache_results' => false,
        'tax_query'    => array(
            array(
                'taxonomy' => $taxonomy,
                'field'    => 'term_id',
                'terms'    => $term_id,
            ),
        ),
    ) );

    foreach ( $ids as $post_id ) {
        // Action Scheduler: spreads the writes out instead of doing them now.
        as_enqueue_async_action( 'te_refresh_single_post', array( $post_id ) );
    }
}

add_action( 'te_refresh_single_post', function ( $post_id ) {
    wp_update_post( array( 'ID' => $post_id ) );
} );

Two query habits make even the queue-building safe: 'cache_results' => false keeps the lookup from flooding the object cache with post data you will not reuse in this request, and a sane numberposts cap (here 200) stops a runaway term from queuing a hundred thousand jobs in one go. On a really large catalog I would page through the IDs over several cron ticks rather than enqueue them all at once.

The blunt truth: this is a niche, surgical trick. Most sites never need it, because most sites read term data fresh on every request and have nothing post-shaped caching it. Reach for it only when you have confirmed that post output is genuinely caching term-derived data and going stale. If you are fighting general ACF or object-cache slowness rather than this specific staleness, the levers are different, I wrote those up in common ACF performance problems at scale.

Put it in a plugin

I keep this in a small site-specific plugin (or mu-plugins), never in the theme. A term-refresh hook is infrastructure, not presentation, and tying it to the active theme means it silently stops working the day someone switches themes.

php
<?php
/**
 * Plugin Name: TE Refresh Posts On Term Save
 * Plugin URI:  https://techearl.com/wordpress-update-post-on-term-save
 * Description: Re-saves posts attached to a taxonomy term when the term is edited, so post-level caches that embed term data refresh.
 * Version:     1.0.0
 * Author:      Ishan Karunaratne
 * Author URI:  https://techearl.com
 * License:     GPL-2.0-or-later
 * Text Domain: te-refresh-on-term-save
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

add_action( 'saved_term', 'te_refresh_posts_on_term_save', 10, 4 );

function te_refresh_posts_on_term_save( $term_id, $tt_id, $taxonomy, $update ) {
    // A brand-new term has no posts yet, so there is nothing to refresh.
    if ( ! $update ) {
        return;
    }

    $posts = get_posts( array(
        'post_type'    => 'any',
        'post_status'  => 'publish',
        'numberposts'  => 1,
        'cache_results' => false,
        'tax_query'    => array(
            array(
                'taxonomy' => $taxonomy,
                'field'    => 'term_id',
                'terms'    => $term_id,
            ),
        ),
    ) );

    if ( ! empty( $posts ) ) {
        wp_update_post( $posts[0] );
    }
}

The $update check is a small but worthwhile guard: it skips the query entirely when a term is first created (no attached posts exist yet), so the only time you do any work is on an actual edit. Restricting post_type to the specific types your taxonomy is registered against, instead of 'any', is a cheap further narrowing if you know them.

Verify it worked

Confirm the refresh actually happened, do not assume it:

  • Check the post's modified time. Re-saving bumps post_modified. Edit a term, then in WP-CLI run wp post get <id> --field=post_modified on a post in that term and confirm the timestamp just moved. Or watch the post list in wp-admin.
  • Diff the cached output. Load the affected post on the front end after the edit and confirm the new term value is showing, not the old one. If you have a full-page cache, this is where you confirm the save_post-triggered purge fired.
  • Log the callback once. While developing, drop a temporary error_log() inside the callback with the $term_id and the count of posts found, edit a term, and tail the log. It tells you the hook fired, with the right arguments, and matched the posts you expected. Pull the log line before shipping.
  • Watch the query count on a big term. If you went with the batched version, edit a heavily-used term and confirm the admin request returns instantly (the writes are queued, not synchronous) and that the background jobs drain over the next few cron runs rather than all at once.

If nothing refreshes, the usual culprits are a tax_query matching no posts (wrong field or a term with genuinely zero attached posts), a callback registered with too few accepted args (so $taxonomy arrives empty), or a page cache you have not actually wired to purge on save_post.

See also

Sources

Authoritative references this article was fact-checked against.

TagsWordPressPerformancePHPTaxonomyCaching

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