Taxonomies Explained
How WordPress models classification: taxonomies, terms, and the three tables that join a term to an object.
wp_terms.term_id -> wp_term_taxonomy.term_taxonomy_id -> wp_term_relationships.object_idA 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
| Table | Columns | Holds |
|---|---|---|
wp_terms | term_id, name, slug, term_group | The word itself |
wp_term_taxonomy | term_taxonomy_id, term_id, taxonomy, description, parent, count | That word's membership of one taxonomy |
wp_term_relationships | object_id, term_taxonomy_id, term_order | Which objects carry it |
wp_termmeta | meta_id, term_id, meta_key, meta_value | Arbitrary 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
<?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.
<?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.
<?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.
<?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