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.
wp_set_object_terms( int $object_id, string|int|array $terms, string $taxonomy, bool $append = false ): array|WP_ErrorFour 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
<?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.
<?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 callsRegistered 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.
<?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' ); // arrayReading 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.
<?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