Working with Terms

Assign, read, look up and annotate terms with wp_set_object_terms, wp_get_post_terms, get_term_by and term meta.

Syntaxwp_set_object_terms( int $object_id, string|int|array $terms, string $taxonomy, bool $append = false ): array|WP_Error

Four functions cover most term work. wp_set_object_terms() writes the relationship rows, wp_get_post_terms() reads them back, get_term_by() resolves a single term from a slug, name or ID, and the *_term_meta() family stores extra data against a term.

Writing

wp_set_object_terms( $object_id, $terms, $taxonomy, $append = false ) replaces the object's terms in that taxonomy by default; passing an empty array removes them all. With $append true it only adds — wp_add_object_terms() is exactly that call with true hard-coded. It returns term taxonomy IDs, not term IDs, or a WP_Error. Terms that do not exist are created, so it is a write path that needs a capability check in front of it.

Reading

wp_get_post_terms( $post_id, $taxonomy = 'post_tag', $args ) accepts any WP_Term_Query argument, including fields, and goes to the database every time. get_the_terms( $post, $taxonomy ) answers the same question but reads the object term cache first, so it is the right call inside the loop.

Term meta

Term meta lives in wp_termmeta and behaves like post meta: add_term_meta(), get_term_meta(), update_term_meta(), delete_term_meta(). Declare keys with register_term_meta( $taxonomy, $meta_key, $args ) (since 4.9.8) to get sanitisation, a default and optional REST exposure.

Example

Example · php
<?php
add_action( 'save_post_book', 'acme_save_book_genres', 10, 1 );

/**
 * Sync a book's genres from the edit screen.
 *
 * @param int $post_id Post being saved.
 */
function acme_save_book_genres( $post_id ) {
    if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return;
    }
    if ( ! isset( $_POST['acme_genre_nonce'] )
        || ! wp_verify_nonce( $_POST['acme_genre_nonce'], 'acme_save_genres' ) ) {
        return;
    }

    // Cast: an int is looked up as a term ID, a string as a slug then a name.
    $term_ids = array_map( 'intval', (array) ( $_POST['acme_genres'] ?? array() ) );
    $term_ids = array_filter( $term_ids );

    // Replaces every acme_genre relationship on this post. An empty array
    // clears them. Returns term taxonomy IDs, not term IDs.
    $tt_ids = wp_set_object_terms( $post_id, $term_ids, 'acme_genre' );

    if ( is_wp_error( $tt_ids ) ) {
        return;
    }

    // Read back as WP_Term objects.
    $terms = wp_get_post_terms( $post_id, 'acme_genre' );

    // Resolve one term by slug and stamp meta on it.
    $featured = get_term_by( 'slug', 'crime', 'acme_genre' ); // WP_Term|false

    if ( $featured ) {
        update_term_meta( $featured->term_id, 'acme_shelf_code', 'CR-01' );
    }
}

When to use it

  • Saving taxonomy selections from a custom metabox or REST endpoint
  • Importing content and attaching terms by slug without creating duplicates
  • Storing a colour, icon or external ID against each term
  • Listing a post's terms as links in a template

More examples

Append and remove, shorthand and longhand

The append flag is the single most common cause of silently lost terms in import and bulk-edit scripts. Use the explicit four-argument form when the intent matters to a reader, and wp_remove_object_terms() when you need to subtract rather than rewrite.

Example · php
<?php
$post_id = 42;

// Shorthand                                     Longhand it delegates to
wp_add_object_terms( $post_id, 'crime', 'acme_genre' );
wp_set_object_terms( $post_id, 'crime', 'acme_genre', true ); // identical

// wp_add_object_terms() is a one-line wrapper: it calls
// wp_set_object_terms( $object_id, $terms, $taxonomy, true ).

// Without the fourth argument the call REPLACES, it does not add:
wp_set_object_terms( $post_id, 'crime', 'acme_genre' ); // now the only genre

// Remove specific terms, keeping the rest.
wp_remove_object_terms( $post_id, array( 'crime', 'noir' ), 'acme_genre' );

// Clear the taxonomy on this object entirely.
wp_set_object_terms( $post_id, array(), 'acme_genre' );

// Reading side, same relationship:
wp_get_post_tags( $post_id );                          // shorthand
wp_get_post_terms( $post_id, 'post_tag' );             // what it calls

Registered term meta, exposed to REST

Registering the key is what gives you sanitisation on write, a default on read and a REST field — an unregistered key still stores fine but is invisible to the block editor. Reach for term meta when a term needs an image, colour, sort weight or external ID.

Example · php
<?php
add_action( 'init', function () {
    // register_term_meta( $taxonomy, $meta_key, $args ) — since WP 4.9.8.
    // $args are register_meta() args with object_subtype set for you.
    register_term_meta( 'acme_genre', 'acme_shelf_code', array(
        'type'              => 'string',
        'single'            => true,
        'default'           => '',        // since WP 5.5
        'description'       => __( 'Physical shelf code.', 'acme' ),
        'sanitize_callback' => 'sanitize_text_field',
        'show_in_rest'      => true,      // appears in /wp/v2/genres meta
        'auth_callback'     => function () {
            return current_user_can( 'manage_categories' );
        },
    ) );
} );

// Read and write like post meta.
$code = get_term_meta( $term_id, 'acme_shelf_code', true ); // string ('' if unset)
update_term_meta( $term_id, 'acme_shelf_code', 'CR-02' );

// Multi-value key: omit 'single', then add repeatedly.
add_term_meta( $term_id, 'acme_alias', 'Detective fiction' );
$aliases = get_term_meta( $term_id, 'acme_alias' ); // array

Reading terms in a template without extra queries

Two functions that look interchangeable but differ in caching. Use get_the_terms() (or has_term()) in templates; save wp_get_post_terms() for code that needs a specific fields shape or ordering.

Example · php
<?php
// Inside the loop. get_the_terms() checks the object term cache first and
// only queries when it misses, so it is safe to call per post.
$genres = get_the_terms( get_the_ID(), 'acme_genre' );

if ( $genres && ! is_wp_error( $genres ) ) {
    foreach ( $genres as $genre ) {
        printf(
            '<a href="%s">%s</a>',
            esc_url( get_term_link( $genre ) ),
            esc_html( $genre->name )
        );
    }
}

// wp_get_post_terms() takes the same arguments as WP_Term_Query and hits
// the database on every call — use it when you need shaped output, not
// when you are rendering one post at a time.
$slugs = wp_get_post_terms( get_the_ID(), 'acme_genre', array(
    'fields'  => 'slugs',   // 'ids', 'names', 'slugs', 'tt_ids', 'all'
    'orderby' => 'name',
) );

// A quick membership test, no term objects needed.
if ( has_term( 'crime', 'acme_genre' ) ) {
    get_template_part( 'template-parts/genre', 'crime' );
}

Discussion

  • Be the first to comment on this lesson.