Why Deleting 5,000 Posts Crawls: wp_update_term_count_now() and Where wp_defer_term_counting() Belongs

Symptom. A maintenance script loops over a few thousand post IDs and calls wp_delete_post( $id, true ). The first hundred deletions finish quickly. Then the script settles into a rhythm that looks like a hang: one post every few seconds, PHP-FPM workers pinned, MySQL showing a repeating pattern of UPDATE wp_term_taxonomy statements. The script is not stuck. It is doing term counting, one post at a time.

This is a trace of that path through core, followed by the placement rule for wp_defer_term_counting() and a verification step that does not rely on optimism.

Trace: what wp_delete_post() actually calls

The wp_delete_post() reference shows the relevant line early in the function, after the before_delete_post action and the trash-meta cleanup:

wp_delete_object_term_relationships( $post_id, get_object_taxonomies( $post->post_type ) );

That is the handoff. wp_delete_post() does not call wp_update_term_count_now() directly. It removes the post’s term relationships, and the count update happens downstream of that removal. The retrieved source for wp_delete_post() confirms the call site; the internals of wp_delete_object_term_relationships() are not in the retrieved passage, so treat the exact chain from relationship removal to count update as the documented behavior of the term API rather than a line-by-line quote from that function.

What the retrieved sources do establish is the counting layer itself. wp_update_term_count() is the function that decides whether to recalculate now or queue the work. Its source contains the branch that matters:

if ( wp_defer_term_counting() ) {
    if ( ! isset( $_deferred[ $taxonomy ] ) ) {
        $_deferred[ $taxonomy ] = array();
    }
    $_deferred[ $taxonomy ] = array_unique( array_merge( $_deferred[ $taxonomy ], $terms ) );
    return true;
}
return wp_update_term_count_now( $terms, $taxonomy );

When deferral is off — the default — every call to wp_update_term_count() falls through to wp_update_term_count_now(). That function is documented as performing the term count update immediately. Its source dispatches to the taxonomy’s update_count_callback if one is registered, otherwise to _update_post_term_count() or _update_generic_term_count(), then calls clean_term_cache(). In a loop over thousands of posts, each post that shares a taxonomy with previously deleted posts triggers that immediate recount for the affected term taxonomy IDs.

This is the cost model. It is not that deletion is inherently slow. It is that term counting is a per-relationship side effect, and without deferral it runs synchronously inside the deletion call.

Root cause: the default is immediate, and the loop multiplies it

Two facts combine badly in a bulk loop:

  1. wp_update_term_count() calls wp_update_term_count_now() whenever wp_defer_term_counting() returns false.
  2. wp_defer_term_counting() is a static flag that defaults to false. Its source is short enough to read in full: a static $_defer, a setter when a boolean is passed, and a flush when the flag is turned off.

So a naive loop does this, per post: remove relationships, call wp_update_term_count(), fall through to wp_update_term_count_now(), run the count callback, clean the term cache. The count callback is not free — it queries the relationship table for the affected term taxonomy IDs. Multiply by the number of posts and the number of taxonomies attached to the post type, and the loop’s cost is dominated by recounting, not by the DELETE on wp_posts.

There is a second path worth knowing about, because it explains why some plugins make deletion slower than core alone. wp_set_object_terms() calls wp_update_term_count( $new_tt_ids, $taxonomy ) when new relationships are added. Any deleted_post or before_delete_post hook that reassigns terms — for example, a workflow plugin that moves a deleted post’s categories to a fallback term — re-enters the same counting path. Deferral covers that too, provided it is enabled before the hook fires.

Fix: wrap the loop, flush once

The placement rule is simple and easy to get wrong: wp_defer_term_counting( true ) goes before the loop, wp_defer_term_counting( false ) goes after it, and both must run even if the loop throws. The following is a hypothetical script, not a benchmark:

<?php
// Hypothetical bulk-deletion script. Not a measured example.
$post_ids = get_posts( array(
    'post_type'      => 'post',
    'post_status'    => 'any',
    'numberposts'    => -1,
    'fields'         => 'ids',
    'suppress_filters' => true,
) );

wp_defer_term_counting( true );
try {
    foreach ( $post_ids as $post_id ) {
        wp_delete_post( $post_id, true );
    }
} finally {
    wp_defer_term_counting( false );
}

What happens inside that wrapper, per the retrieved sources:

  • Each wp_update_term_count() call sees the deferral flag set, merges the affected term taxonomy IDs into a per-taxonomy array, deduplicates with array_unique(), and returns true without recalculating.
  • wp_defer_term_counting( false ) sets the flag back and calls wp_update_term_count( null, null, true ).
  • That call, with $do_deferred true, iterates the deferred taxonomies and calls wp_update_term_count_now() once per taxonomy.

The mechanism is batching, not skipping. Counts are still recalculated; they are recalculated once per taxonomy after the loop instead of once per post inside it. Because the queue is deduplicated per taxonomy, a term that appears in five hundred deleted posts is counted once at flush time, not five hundred times.

Two placement errors are common:

  • Enabling deferral inside the loop. The first iteration runs with deferral off, and the flag is toggled repeatedly. The batching benefit disappears, and the flush semantics become confusing.
  • Forgetting the flush. If the script exits without wp_defer_term_counting( false ), the deferred queue is never flushed in that request. Counts remain stale until something else triggers a recount. The finally block above exists for this reason.

One caveat about the documentation: the user-contributed note on the wp_defer_term_counting() reference describes the function as designed to speed up inserting multiple posts during an import. The retrieved sources do not document it for bulk deletion specifically. The reasoning that it applies to deletion follows from the code path — deletion removes term relationships, and relationship removal flows through wp_update_term_count() — not from a documented guarantee. Treat it as a mechanism you can verify locally, not a promise from the handbook.

WP-CLI: the flag already exists

If the deletion is a one-off maintenance task, you may not need a custom script. The wp post delete command documents a --defer-term-counting flag, described as recalculating term count in batch for a performance boost. The command also supports --force to skip the trash, which matters because wp_delete_post() moves posts to Trash unless Trash is disabled, the item is already trashed, or $force_delete is true.

wp post delete $(wp post list --post_type=post --post_status=any --format=ids) --force --defer-term-counting

The same placement logic applies: the flag batches the recount, and the command is responsible for flushing. If you are writing your own script instead of using WP-CLI, you own the try/finally.

Verification: do not assume the flush covered everything

After a deferred bulk operation, confirm the counts rather than trusting the flush. The wp term recount command runs wp_update_term_count() on a taxonomy’s terms to bring counts back to the correct value, and its documentation notes it is intended for cases where manual database changes have left counts out of sync.

wp term recount category post_tag

If the recount reports changes, the deferred flush did not cover every taxonomy — usually because a taxonomy was registered after the deferral window, or because a hook modified relationships outside the loop. If it reports no changes, the flush did its job.

For a direct database check, compare the stored count against the relationship table for a sample of terms:

SELECT tt.term_taxonomy_id, tt.count AS stored_count, COUNT(tr.object_id) AS actual_count
FROM wp_term_taxonomy AS tt
LEFT JOIN wp_term_relationships AS tr
    ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE tt.taxonomy = 'category'
GROUP BY tt.term_taxonomy_id
HAVING stored_count <> actual_count;

An empty result set is the verification. A non-empty one tells you which terms to recount and, more usefully, that something in your deletion path bypassed the deferral window.

Diagnostic checklist

  1. Confirm deferral is on during the loop. Log wp_defer_term_counting() at the top of the first iteration; it should return true. If it returns false, the flag was set after the loop started or was reset by another callback.
  2. Confirm the flush runs once. The finally block should execute exactly once per script run. If the script is invoked per-post by a queue worker, deferral cannot batch across invocations — the batching boundary is the request.
  3. Confirm no hook re-enters counting outside the window. A deleted_post callback that calls wp_set_object_terms() after the flush will trigger an immediate recount. Check hook priorities and whether the callback runs before or after your finally.
  4. Confirm counts afterward. Run wp term recount for each affected taxonomy and inspect the HAVING query above.

The failure mode is not mysterious once the path is visible: deletion removes relationships, relationship removal calls the term count updater, and the updater recalculates immediately unless deferral is enabled. The fix is not a faster database or a bigger worker pool. It is moving the recount out of the per-post path and into a single flush after the loop — and then verifying that the flush actually ran.