
How to Create Custom Taxonomies in WordPress?
Custom taxonomies let you group any content, including custom post types, by criteria that categories and tags were never designed to handle. Categories and Tags are themselves just taxonomies, hierarchical and non-hierarchical respectively, that happen to be built into WordPress core and wired to the post post type by default. register_taxonomy() is the exact same mechanism, and it works on any post type, including your own.
If you've registered a "Product" post type, register_taxonomy() is how you add "Product Category" (hierarchical, like Categories) or "Product Brand" (non-hierarchical, like Tags) without contaminating your blog's actual Category list with product-only terms. Mixing unrelated content into the built-in Category taxonomy is one of the most common WordPress content-modeling mistakes, and it's avoidable with one function call.
This guide covers registering both hierarchical and non-hierarchical taxonomies, attaching them to one or multiple post types, customizing their admin labels, querying content by taxonomy term, and exposing them to the REST API and block editor.
Hierarchical vs. Non-Hierarchical: Which One Do You Need?
- Hierarchical taxonomies (like Categories) support parent/child relationships and render as checkboxes in the admin UI, since a post can belong to multiple terms across the hierarchy at once. Use these for broad classification, like "Electronics > Laptops > Gaming Laptops."
- Non-hierarchical taxonomies (like Tags) are flat and render as a free-form, comma-separated tag input in the admin UI with autocomplete against existing terms. Use these for descriptive, non-nested labels, like "waterproof" or "on-sale."
Picking correctly up front matters because the admin UI, the REST API shape, and even some template functions behave slightly differently between the two.
Step 1: Register a Hierarchical Taxonomy
Here's a complete "Product Category" taxonomy attached to a product post type, registered on the init hook exactly like a post type:
add_action( 'init', function () {
$labels = [
'name' => 'Product Categories',
'singular_name' => 'Product Category',
'menu_name' => 'Categories',
'all_items' => 'All Categories',
'parent_item' => 'Parent Category',
'parent_item_colon' => 'Parent Category:',
'new_item_name' => 'New Category Name',
'add_new_item' => 'Add New Category',
'edit_item' => 'Edit Category',
'update_item' => 'Update Category',
'view_item' => 'View Category',
'separate_items_with_commas' => 'Separate categories with commas',
'search_items' => 'Search Categories',
'not_found' => 'Not Found',
'no_terms' => 'No categories',
'items_list_navigation' => 'Categories list navigation',
'items_list' => 'Categories list',
];
register_taxonomy( 'product_category', [ 'product' ], [
'labels' => $labels,
'hierarchical' => true,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_admin_column' => true,
'show_in_nav_menus' => true,
'show_in_rest' => true,
'rest_base' => 'product-categories',
'query_var' => true,
'rewrite' => [
'slug' => 'product-category',
'with_front' => false,
'hierarchical' => true,
],
] );
} );
Key arguments worth understanding:
- The second parameter (
[ 'product' ]) is an array of post type slugs this taxonomy attaches to. It can list more than one, for example[ 'product', 'coupon' ], if two post types should share the same category list. hierarchical: trueis what gives this the checkbox UI and parent/child support. This single argument is the entire difference between building "Categories" and "Tags."show_admin_column: trueadds a column to the post type's admin list table showing each item's assigned terms, which otherwise you'd only see by opening the editor.rewrite.hierarchical: truemakes child term URLs nest under their parent, like/product-category/electronics/laptops/, matching how built-in Categories behave.
Step 2: Register a Non-Hierarchical Taxonomy
A "Product Brand" taxonomy, meant to behave like Tags:
add_action( 'init', function () {
register_taxonomy( 'product_brand', [ 'product' ], [
'labels' => [
'name' => 'Brands',
'singular_name' => 'Brand',
'search_items' => 'Search Brands',
'popular_items' => 'Popular Brands',
'add_new_item' => 'Add New Brand',
'menu_name' => 'Brands',
],
'hierarchical' => false,
'public' => true,
'show_ui' => true,
'show_admin_column' => true,
'show_in_rest' => true,
'show_tagcloud' => true,
'update_count_callback' => '_update_post_term_count',
'query_var' => true,
'rewrite' => [ 'slug' => 'brand' ],
] );
} );
The only structural difference from Step 1 is hierarchical: false, but the labels change meaningfully too, popular_items and separate_items_with_commas only make sense in a flat, comma-separated context, which is why non-hierarchical taxonomies use a different default label set than hierarchical ones under the hood.
Step 3: Attach a Taxonomy to Multiple Post Types
Taxonomies aren't locked to a single post type. If both "Product" and "Service" post types should share the same brand list, list both when registering:
register_taxonomy( 'product_brand', [ 'product', 'service' ], [
// ...same args as above
] );
Alternatively, attach an already-registered taxonomy to an additional post type later with register_taxonomy_for_object_type(), useful if the second post type is registered by a different plugin than the one that owns the taxonomy:
add_action( 'init', function () {
register_taxonomy_for_object_type( 'product_brand', 'service' );
}, 20 ); // run after both the taxonomy and post type are registered
Step 4: Query Content by Taxonomy Term
WP_Query's tax_query argument filters posts by taxonomy term, and works identically for custom taxonomies as it does for Category and Tag:
$laptops = new WP_Query( [
'post_type' => 'product',
'tax_query' => [
[
'taxonomy' => 'product_category',
'field' => 'slug',
'terms' => [ 'laptops' ],
],
],
] );
Combining two taxonomy filters (products that are both "Laptops" AND made by "Acme"):
$acme_laptops = new WP_Query( [
'post_type' => 'product',
'tax_query' => [
'relation' => 'AND',
[
'taxonomy' => 'product_category',
'field' => 'slug',
'terms' => [ 'laptops' ],
],
[
'taxonomy' => 'product_brand',
'field' => 'slug',
'terms' => [ 'acme' ],
],
],
] );
The relation key defaults to 'AND' when omitted, but is worth setting explicitly whenever you have more than one clause, since it's easy to misread the query's intent months later without it.
Step 5: Display Terms in a Template
get_the_terms() retrieves the terms attached to the current post, and the_terms() outputs them directly as a linked, comma-separated list:
<?php
$brands = get_the_terms( get_the_ID(), 'product_brand' );
if ( $brands && ! is_wp_error( $brands ) ) :
foreach ( $brands as $brand ) :
printf(
'<a href="%s">%s</a> ',
esc_url( get_term_link( $brand ) ),
esc_html( $brand->name )
);
endforeach;
endif;
?>
Always check ! is_wp_error( $brands ), get_the_terms() returns a WP_Error object, not false or an empty array, if the taxonomy itself doesn't exist or isn't registered for the current post type, and treating a WP_Error object as a truthy iterable causes a fatal error rather than an empty, harmless loop.
Step 6: Add Custom Term Meta
Just like posts can have post meta, taxonomy terms can have their own meta fields, useful for something like a brand logo attached to a "Brand" term rather than a post:
add_action( 'product_brand_add_form_fields', function () {
?>
<div class="form-field">
<label for="brand-logo-id">Brand Logo (Attachment ID)</label>
<input type="number" name="brand_logo_id" id="brand-logo-id" value="" />
</div>
<?php
} );
add_action( 'created_product_brand', function ( $term_id ) {
if ( isset( $_POST['brand_logo_id'] ) ) {
update_term_meta( $term_id, 'logo_id', absint( $_POST['brand_logo_id'] ) );
}
} );
Retrieving it later is a single get_term_meta() call, mirroring get_post_meta():
$logo_id = get_term_meta( $brand->term_id, 'logo_id', true );
$logo_url = $logo_id ? wp_get_attachment_image_url( $logo_id, 'thumbnail' ) : '';
Archive Templates for Custom Taxonomies
WordPress resolves taxonomy archive templates following the same template hierarchy pattern as post types: taxonomy-product_category.php, or even more specifically taxonomy-product_category-laptops.php for a single term, falling back to taxonomy.php, then archive.php.
<?php
// taxonomy-product_category.php
get_header();
$term = get_queried_object();
?>
<h1><?php echo esc_html( $term->name ); ?></h1>
<p><?php echo esc_html( term_description( $term ) ); ?></p>
<?php while ( have_posts() ) : the_post(); ?>
<article <?php post_class(); ?>>
<h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
</article>
<?php endwhile; ?>
<?php get_footer(); ?>
Flushing Rewrite Rules
Exactly as with custom post types, a newly registered taxonomy's pretty permalinks (/product-category/laptops/) won't resolve until the rewrite rules cache is rebuilt. Flush it once on plugin activation, never on every request:
register_activation_hook( __FILE__, function () {
tidewave_register_product_taxonomies();
flush_rewrite_rules();
} );
During local development, visiting Settings → Permalinks and clicking Save Changes does the same thing without any code.
Step 7: Set a Default Term for New Posts
Unlike Categories, which fall back to "Uncategorized" automatically, a custom taxonomy has no built-in default, a product saved without a category simply has none. If every product should belong to at least one category, set a default explicitly on the save_post hook:
add_action( 'save_post_product', function ( $post_id, $post, $update ) {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
$terms = wp_get_post_terms( $post_id, 'product_category', [ 'fields' => 'ids' ] );
if ( empty( $terms ) ) {
wp_set_post_terms( $post_id, [ 'uncategorized-product' ], 'product_category' );
}
}, 10, 3 );
This assumes an uncategorized-product term already exists; create it once, either manually in wp-admin or programmatically with wp_insert_term() on plugin activation, since wp_set_post_terms() won't silently create a missing term slug for you.
Step 8: Restrict a Taxonomy to a Single Term per Post
Some taxonomies conceptually should behave like a single-select "status" field rather than a multi-select grouping, for instance a "Product Condition" taxonomy where a product is either "New" or "Refurbished," never both. WordPress's core UI doesn't offer a built-in single-select taxonomy control, but wp_set_post_terms() with $append set to false (its default) already replaces rather than adds to existing terms, so enforcing single-selection just means checking the count after a save and trimming to the most recently added term:
add_action( 'set_object_terms', function ( $post_id, $terms, $tt_ids, $taxonomy ) {
if ( 'product_condition' !== $taxonomy || count( $tt_ids ) <= 1 ) {
return;
}
$latest = end( $tt_ids );
wp_set_object_terms( $post_id, (int) $latest, $taxonomy, false );
}, 10, 4 );
For a genuinely better editing experience, though, swapping the taxonomy's default checkbox meta box for a radio-button one via the {taxonomy}_checklist filter, or simply using an ACF Select field mapped to the taxonomy, both give editors a clearer single-choice UI than a workaround like the one above.
Frequently Asked Questions (FAQ) About Custom Taxonomies
Yes, list 'post' alongside your custom post type slug in register_taxonomy()'s second argument, for example register_taxonomy( 'product_brand', [ 'post', 'product' ], $args ). The taxonomy's meta box then appears on both post types' editor screens.
None, mechanically. Categories and Tags are both taxonomies registered with register_taxonomy() by WordPress core itself (as 'category' and 'post_tag'), attached to the 'post' post type. A custom taxonomy uses the identical function; the only difference is you're choosing the name, hierarchy, and which post types it attaches to.
It returns WP_Error specifically when the taxonomy passed in isn't registered for the current post's post type, which usually indicates a bug (a typo in the taxonomy slug, or code running before the taxonomy is registered) rather than a normal 'no terms assigned' state — which instead returns an empty array. Always check with is_wp_error() before looping over the result.
Yes, via term meta, added with the add_form_fields and edit_form_fields action hooks for the input UI, saved on the created and edited hooks, and read back with get_term_meta(). ACF also supports adding fields to taxonomy term edit screens directly through its field group location rules.
Change the hierarchical argument to false in the registration array. Existing parent/child term relationships in the database aren't deleted, but the admin UI switches to the flat, comma-separated tag interface and stops displaying or enforcing the hierarchy going forward.
Yes. Without show_in_rest set to true, the taxonomy's term selector won't appear in the block editor's sidebar for an otherwise REST-enabled post type, even though it still works fine in the classic editor and via direct WP_Query calls.
Yes, terms are scoped per-taxonomy in the database (the wp_terms table is shared, but wp_term_taxonomy links each term to a specific taxonomy), so identical term names in different taxonomies don't conflict and are tracked as entirely separate entities.
Conclusion
register_taxonomy() gives you the same categorization power that Categories and Tags provide, applied to whatever content model your site actually needs, without polluting your blog's taxonomy with unrelated terms. The one decision that matters most upfront is hierarchical versus non-hierarchical, since it changes both the admin UI and the default label set, and it's awkward, though not impossible, to change after editors have built up real content around it.
Pair a custom taxonomy with a custom post type for the classification layer, and reach for ACF when you need structured data that isn't really a "term" at all, like a price or a SKU.
Here are a few additional resources if you want to go deeper:
- WordPress Developer Reference: register_taxonomy() — the complete list of accepted arguments.
- WordPress Developer Handbook: Taxonomies — the official plugin handbook chapter on custom taxonomies.
- WordPress Developer Reference: WP_Query tax_query parameters — the full reference for filtering queries by taxonomy term.


