Taxonomies Explained

How WordPress models classification: taxonomies, terms, and the three tables that join a term to an object.

Syntaxwp_terms.term_id -> wp_term_taxonomy.term_taxonomy_id -> wp_term_relationships.object_id

A taxonomy is a named scheme for classifying objects. WordPress 6.8 registers eight of them at startup — category, post_tag, post_format, link_category, nav_menu, wp_theme, wp_template_part_area and wp_pattern_category — through the same register_taxonomy() call you use for your own. Categories are not a separate feature: they are a hierarchical taxonomy with a checkbox metabox. Tags are a flat one with a comma-separated field.

Hierarchical vs flat

The hierarchical argument decides two things: whether a term may have a parent, and which metabox the editor renders — post_categories_meta_box() for hierarchical, post_tags_meta_box() for flat. The storage is identical either way; a flat taxonomy simply never sets parent.

Terms vs term relationships

TableColumnsHolds
wp_termsterm_id, name, slug, term_groupThe word itself
wp_term_taxonomyterm_taxonomy_id, term_id, taxonomy, description, parent, countThat word's membership of one taxonomy
wp_term_relationshipsobject_id, term_taxonomy_id, term_orderWhich objects carry it
wp_termmetameta_id, term_id, meta_key, meta_valueArbitrary data per term

The split matters. A relationship row points at a term in a taxonomy, not at a term, and the table has no post-type column — object_id can be a post, an attachment, a link, or anything else your code relates. Most template helpers are thin wrappers: get_the_category( $post_id ) calls get_the_terms( $post_id, 'category' ) and then normalises the result.

Example

Example · php
<?php
// The same question asked two ways: which 'acme_genre' terms is post 42 in?

// 1. Through the API. get_the_terms() reads the object term cache when primed.
$terms = get_the_terms( 42, 'acme_genre' ); // WP_Term[]|false|WP_Error

if ( $terms && ! is_wp_error( $terms ) ) {
    foreach ( $terms as $term ) {
        // WP_Term: term_id, name, slug, term_group, term_taxonomy_id,
        // taxonomy, description, parent, count.
        echo esc_html( $term->name ) . ' (' . (int) $term->count . ')';
    }
}

// 2. The join the API performs, written out. Note the relationship row is
//    matched on term_taxonomy_id: the taxonomy membership, not the term.
global $wpdb;

$sql = "SELECT t.term_id, t.name, t.slug, tt.parent, tt.count
        FROM {$wpdb->term_relationships} AS tr
        INNER JOIN {$wpdb->term_taxonomy} AS tt
                ON tt.term_taxonomy_id = tr.term_taxonomy_id
        INNER JOIN {$wpdb->terms} AS t
                ON t.term_id = tt.term_id
        WHERE tr.object_id = %d
          AND tt.taxonomy = %s";

$rows = $wpdb->get_results( $wpdb->prepare( $sql, 42, 'acme_genre' ) );

When to use it

  • Deciding whether a new field should be a taxonomy or post meta
  • Writing a reporting query that joins posts to their terms directly in SQL
  • Explaining why a term's count differs from the number of posts you can see
  • Auditing which taxonomies a plugin has attached to your post types

More examples

Hierarchical and flat, side by side

The one argument that separates a category-like taxonomy from a tag-like one. Reach for this when deciding how to model a field: use hierarchical when editors need a fixed, nestable vocabulary, flat when they type values freely.

Example · php
<?php
add_action( 'init', function () {
    // Hierarchical: terms may have a parent, checkbox tree in the editor.
    register_taxonomy( 'acme_genre', 'book', array(
        'hierarchical' => true,
        'public'       => true,
        'show_in_rest' => true,
    ) );

    // Flat: no parent, free-text comma field in the editor.
    register_taxonomy( 'acme_mood', 'book', array(
        'hierarchical' => false, // this is the default
        'public'       => true,
        'show_in_rest' => true,
    ) );
} );

// Both write to the same three tables. The only structural difference is
// that acme_mood rows never set wp_term_taxonomy.parent.

Built-in helpers are wrappers over the generic functions

Shows that the category and tag functions carry no special privileges — they pass a hard-coded taxonomy name to the generic function. Useful when you are reading older code, or when you want one code path that handles categories, tags and your custom taxonomy alike.

Example · php
<?php
$post_id = 42;

// Shorthand                          // Longhand it delegates to
get_the_category( $post_id );         get_the_terms( $post_id, 'category' );
wp_get_post_tags( $post_id );         wp_get_post_terms( $post_id, 'post_tag' );
wp_delete_category( 7 );              wp_delete_term( 7, 'category' );

// These are the same calls. get_the_category() adds two things on top of
// get_the_terms(): it always returns an array (never false or WP_Error) and
// it runs _make_cat_compat() to restore the pre-2.3 cat_ID / cat_name
// properties on each object.
// wp_get_post_tags( $post_id, $args ) is literally:
//     wp_get_post_terms( $post_id, 'post_tag', $args );

Ask the registry what exists

Registration happens at runtime, so the only reliable source of truth is the registry, not a hard-coded list. Use this in admin screens, import scripts and migration code that must adapt to whatever taxonomies the site has.

Example · php
<?php
// Every taxonomy attached to the 'book' post type.
$names = get_object_taxonomies( 'book' );            // string[]
$objects = get_object_taxonomies( 'book', 'objects' ); // WP_Taxonomy[]

// Every public taxonomy on the site, as objects.
$public = get_taxonomies( array( 'public' => true ), 'objects' );

foreach ( $public as $taxonomy ) {
    // WP_Taxonomy: name, label, hierarchical, object_type, rewrite,
    // show_in_rest, cap, etc.
    printf(
        "%s: %s, attached to %s\n",
        $taxonomy->name,
        $taxonomy->hierarchical ? 'hierarchical' : 'flat',
        implode( ', ', $taxonomy->object_type )
    );
}

// Guard before touching one you did not register yourself.
if ( taxonomy_exists( 'acme_genre' ) ) {
    $genre = get_taxonomy( 'acme_genre' ); // WP_Taxonomy|false
}

Discussion

  • Be the first to comment on this lesson.