WordPress wp_set_post_terms(): Assign, Replace, and Append Taxonomy Terms Programmatically

Every WordPress post has taxonomy terms — categories and tags on standard posts, and custom taxonomy terms on custom post types. Managing these relationships programmatically is required in import scripts, REST API callbacks, automated content categorisation pipelines, and admin bulk actions. WordPress provides several functions for this, and choosing the wrong one causes either data loss or accumulation of duplicate assignments. wp_set_post_terms() is the high-level wrapper that handles slug-to-ID resolution, creates missing terms on the fly, and returns the term taxonomy IDs that were set. Its $append flag controls whether the new terms replace all existing terms or are added to them — a distinction that matters enormously in bulk operations.

Problem: An import script assigns categories to imported posts. Running it twice should not create duplicate assignments, and it should support both a "replace all" mode (full re-categorisation) and an "add" mode (append without removing existing terms).

Solution: Use wp_set_post_terms() with $append = false to replace all terms, or $append = true to add without removing. Pass term IDs for reliability, or term names/slugs and let WordPress resolve them.

<?php
$post_id = 42;

// ── Replace ALL existing category assignments ──────────────────────────
// $append = false (default): removes existing terms, sets only these
$result = wp_set_post_terms(
    $post_id,
    [ 3, 7, 12 ],   // array of term IDs
    'category',
    false            // replace — do NOT append
);
// $result = array of term taxonomy IDs on success, WP_Error on failure, false if $post_id invalid

// ── Append: add without removing existing terms ───────────────────────
wp_set_post_terms( $post_id, [ 15 ], 'category', true ); // append

// ── Pass term slugs or names — WordPress resolves or creates them ─────
wp_set_post_terms( $post_id, [ 'wordpress', 'php', 'mysql' ], 'post_tag' );
// Creates tags if they don't exist, assigns by ID internally

// ── Assign terms to a custom taxonomy ─────────────────────────────────
wp_set_post_terms( $post_id, [ $electronics_term_id ], 'product_cat' );

// ── Remove ALL terms from a taxonomy ──────────────────────────────────
wp_set_post_terms( $post_id, [], 'category' );    // sets empty = removes all

// ── Lower-level: wp_set_object_terms ──────────────────────────────────
// Same as wp_set_post_terms but works for any object type (post, user, etc.)
wp_set_object_terms( $post_id, [ 3, 7 ], 'category', false );

// ── Check current terms before deciding to replace or append ──────────
$existing_ids = wp_get_post_terms( $post_id, 'category', [ 'fields' => 'ids' ] );
// $existing_ids = [3, 7] (array of integers)

$new_ids       = [ 7, 12, 20 ];
$merged_ids    = array_unique( array_merge( $existing_ids, $new_ids ) );
wp_set_post_terms( $post_id, $merged_ids, 'category', false );

// ── Verify the result ─────────────────────────────────────────────────
if ( is_wp_error( $result ) ) {
    error_log( 'Term assignment failed: ' . $result->get_error_message() );
}

// ── Flush term count cache after bulk operations ──────────────────────
// wp_set_post_terms() updates term counts automatically, but after a bulk
// import you may want to force a full recount:
wp_update_term_count_now( $term_taxonomy_ids, 'category' );

NOTE: wp_set_post_terms() calls wp_insert_term() internally when a string slug or name is passed that does not exist yet — this means it creates new terms automatically. This is convenient for import scripts but can be a source of unintended term creation if input data is not sanitised first. Always sanitise and validate term names before passing them, especially when the source is user input or external API data. For high-volume bulk assignments, use wp_set_object_terms() in combination with explicit IDs (not names) to avoid the overhead of the term lookup/creation path for every item.