
How to Register Custom Taxonomies in WordPress?
A recipe site starts with categories like Breakfast, Dinner, and Desserts. Then the editors want to filter by cuisine, by diet, and by cooking time. Soon the category list holds "Italian", "Vegan", "Under 30 Minutes", and "Dinner" side by side, the hierarchy makes no sense, and nobody can build a clean "all vegan Italian dinners" page. The fix is not more categories. It is custom taxonomies: separate, purpose-built ways to group content, each with its own admin screen, URLs, and archive pages.
This article covers what a taxonomy is, how to register one with register_taxonomy(), every argument that matters in practice, how to make it work in the block editor, how to attach it to custom post types, how URLs and rewrite rules behave, how to build archive templates for classic and block themes, and how to query, display, and manage terms in code and with WP-CLI.
What Is a Taxonomy in WordPress?
A taxonomy is a way of grouping content. Each taxonomy holds terms, and each term can be assigned to many posts. WordPress ships with two public taxonomies you already know:
- Categories (
category): hierarchical, so terms can have parents and children - Tags (
post_tag): flat, so every term sits at the same level
If you are unsure when to use each, the differences are covered in detail in what is the difference between categories and tags in WordPress.
A custom taxonomy is one you register yourself. It behaves exactly like categories or tags, but it only contains the terms that belong to its purpose. For the recipe site above, a sensible model looks like this:
| Taxonomy | Type | Example terms |
|---|---|---|
| Course | Hierarchical | Breakfast, Lunch, Dinner, Dessert |
| Cuisine | Hierarchical | Italian, Mexican, Indian → South Indian |
| Diet | Flat | Vegan, Gluten-Free, Keto |
| Category | Built-in | Left for editorial sections only |
Each taxonomy gets its own admin menu item, its own URL base such as /cuisine/italian/, and its own archive. Queries become simple: "posts in Cuisine: Italian AND Diet: Vegan" is a two-line tax_query.
When to use a taxonomy instead of a custom field
A common modeling question is whether a piece of data should be a taxonomy term or a custom field (post meta). A useful rule:
- Use a taxonomy when many posts share the same value and you want to group, filter, or list posts by it. Cuisine, genre, location, and skill level are taxonomies.
- Use a custom field when the value is unique or nearly unique per post, or numeric. Price, cooking time in minutes, ISBN, and event date are custom fields.
Taxonomy queries are indexed and fast. Filtering thousands of posts by a meta value is much slower, so the choice has performance consequences as well as editorial ones.
Registering Your First Custom Taxonomy
Taxonomies are registered with register_taxonomy() on the init hook. Put the code in a small plugin, not your theme, so the taxonomy and its terms survive a theme switch. A must-use plugin or a regular site plugin both work. For safe places to put code like this, see how to add custom code snippets to WordPress safely.
Here is a complete plugin that registers a hierarchical Cuisine taxonomy for posts:
<?php
// wp-content/plugins/recipe-taxonomies/recipe-taxonomies.php
/**
* Plugin Name: Recipe Taxonomies
* Description: Registers the Cuisine and Diet taxonomies.
* Version: 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function rt_register_cuisine_taxonomy() {
$labels = array(
'name' => _x( 'Cuisines', 'taxonomy general name', 'recipe-taxonomies' ),
'singular_name' => _x( 'Cuisine', 'taxonomy singular name', 'recipe-taxonomies' ),
'search_items' => __( 'Search Cuisines', 'recipe-taxonomies' ),
'all_items' => __( 'All Cuisines', 'recipe-taxonomies' ),
'parent_item' => __( 'Parent Cuisine', 'recipe-taxonomies' ),
'parent_item_colon' => __( 'Parent Cuisine:', 'recipe-taxonomies' ),
'edit_item' => __( 'Edit Cuisine', 'recipe-taxonomies' ),
'view_item' => __( 'View Cuisine', 'recipe-taxonomies' ),
'update_item' => __( 'Update Cuisine', 'recipe-taxonomies' ),
'add_new_item' => __( 'Add New Cuisine', 'recipe-taxonomies' ),
'new_item_name' => __( 'New Cuisine Name', 'recipe-taxonomies' ),
'not_found' => __( 'No cuisines found.', 'recipe-taxonomies' ),
'back_to_items' => __( '← Back to Cuisines', 'recipe-taxonomies' ),
'menu_name' => __( 'Cuisines', 'recipe-taxonomies' ),
);
register_taxonomy(
'cuisine',
array( 'post' ),
array(
'labels' => $labels,
'hierarchical' => true,
'public' => true,
'show_ui' => true,
'show_admin_column' => true,
'show_in_rest' => true,
'query_var' => true,
'rewrite' => array(
'slug' => 'cuisine',
'with_front' => false,
'hierarchical' => true,
),
)
);
}
add_action( 'init', 'rt_register_cuisine_taxonomy' );
Activate the plugin and a Posts → Cuisines menu appears. In the block editor, a Cuisines panel shows up in the post sidebar, and the list table gains a Cuisines column.
Choosing a taxonomy key
The first argument, cuisine, is the taxonomy key. It is stored in the database for every term, so choose it carefully:
- Maximum 32 characters, lowercase letters, numbers, underscores, and dashes only.
- Do not use reserved names. WordPress uses many query variables internally, and a taxonomy named
type,author,year,name,page,order,term, orcategory_namewill break queries in confusing ways. Prefixing, for examplert_cuisine, avoids collisions entirely, at the cost of a less pretty key. The rewrite slug can still be plaincuisine. - Do not rename it later. Changing the key orphans every existing term assignment until you migrate them with SQL or WP-CLI.
The register_taxonomy Arguments That Matter
register_taxonomy() accepts many arguments. These are the ones you will set on almost every project:
| Argument | Default | What it does |
|---|---|---|
hierarchical | false | true behaves like categories, false like tags |
public | true | Controls front-end visibility and the defaults of several flags |
publicly_queryable | Value of public | Whether term archives and query vars work on the front end |
show_ui | Value of public | Shows the admin screen and post editor panel |
show_in_menu | Value of show_ui | Adds the submenu under the post type menu |
show_in_nav_menus | Value of public | Lets terms be added to navigation menus |
show_in_rest | false | Exposes the taxonomy to the REST API and the block editor |
rest_base | Taxonomy key | Changes the REST route, for example /wp/v2/cuisines |
show_admin_column | false | Adds a column to the post list table |
show_tagcloud | Value of show_ui | Allows the Tag Cloud block to use it |
query_var | Taxonomy key | Enables ?cuisine=italian style queries |
rewrite | true | Pretty permalinks for term archives |
default_term | None | A term assigned automatically when none is chosen |
capabilities | Category-like defaults | Which capabilities manage, edit, delete, and assign terms |
meta_box_cb | Based on hierarchical | The classic editor meta box; false hides it |
sort | None | Whether to remember the order terms were added to a post |
show_in_rest is not optional anymore
The block editor is a JavaScript application that talks to WordPress through the REST API. If show_in_rest is false, which is the default, your taxonomy will not appear in the block editor at all. Editors can only assign terms through Quick Edit in the post list. This is the most common reason a "registered" taxonomy seems to be missing. Set show_in_rest => true unless you deliberately want the taxonomy hidden from the editor and the API.
Hierarchical versus flat, in the editor
The hierarchical flag changes more than parent relationships. It also changes the editor UI:
- Hierarchical taxonomies get a checkbox list, like categories. Editors pick from existing terms and can add new ones with a parent.
- Flat taxonomies get a token field, like tags. Editors type terms and press Enter, and new terms are created on the fly.
Pick the UI your editors need. A controlled vocabulary such as Course works best as checkboxes. A free-form vocabulary such as ingredients works best as tokens.
Restricting who can manage terms
By default, anyone who can edit posts can assign terms, and anyone with manage_categories can create, edit, and delete them. If you want a fixed vocabulary that authors can assign but not extend, map custom capabilities:
<?php
// wp-content/plugins/recipe-taxonomies/recipe-taxonomies.php (inside the args array)
'capabilities' => array(
'manage_terms' => 'manage_options',
'edit_terms' => 'manage_options',
'delete_terms' => 'manage_options',
'assign_terms' => 'edit_posts',
),
Now only administrators can change the list of cuisines, while authors and editors can still tag their recipes. If you need finer control over roles, see how to set up and manage user roles in WordPress.
Registering a Flat Taxonomy with a Default Term
Here is the second taxonomy for the recipe site, a flat Diet taxonomy, added to the same plugin. It also shows default_term, which creates a term and assigns it to posts saved without one:
<?php
// wp-content/plugins/recipe-taxonomies/recipe-taxonomies.php
function rt_register_diet_taxonomy() {
register_taxonomy(
'diet',
array( 'post' ),
array(
'labels' => array(
'name' => __( 'Diets', 'recipe-taxonomies' ),
'singular_name' => __( 'Diet', 'recipe-taxonomies' ),
'search_items' => __( 'Search Diets', 'recipe-taxonomies' ),
'popular_items' => __( 'Popular Diets', 'recipe-taxonomies' ),
'all_items' => __( 'All Diets', 'recipe-taxonomies' ),
'edit_item' => __( 'Edit Diet', 'recipe-taxonomies' ),
'add_new_item' => __( 'Add New Diet', 'recipe-taxonomies' ),
'new_item_name' => __( 'New Diet Name', 'recipe-taxonomies' ),
'separate_items_with_commas' => __( 'Separate diets with commas', 'recipe-taxonomies' ),
'add_or_remove_items' => __( 'Add or remove diets', 'recipe-taxonomies' ),
'choose_from_most_used' => __( 'Choose from the most used diets', 'recipe-taxonomies' ),
'not_found' => __( 'No diets found.', 'recipe-taxonomies' ),
'menu_name' => __( 'Diets', 'recipe-taxonomies' ),
),
'hierarchical' => false,
'show_admin_column' => true,
'show_in_rest' => true,
'rewrite' => array( 'slug' => 'diet', 'with_front' => false ),
'default_term' => array(
'name' => __( 'Any Diet', 'recipe-taxonomies' ),
'slug' => 'any-diet',
),
)
);
}
add_action( 'init', 'rt_register_diet_taxonomy' );
The default term behaves like "Uncategorized" for categories: it cannot be deleted from the admin while it is the default.
Attaching Taxonomies to Custom Post Types
The second argument of register_taxonomy() is the list of post types the taxonomy applies to. It can be built-in types, custom types, or both:
<?php
register_taxonomy( 'cuisine', array( 'post', 'recipe' ), $args );
When the post type is registered by another plugin, or you want to attach an existing taxonomy to a new type, use register_taxonomy_for_object_type() after both exist:
<?php
// wp-content/plugins/recipe-taxonomies/recipe-taxonomies.php
function rt_attach_taxonomies() {
register_taxonomy_for_object_type( 'cuisine', 'recipe' );
register_taxonomy_for_object_type( 'post_tag', 'recipe' );
}
add_action( 'init', 'rt_attach_taxonomies', 20 );
The priority of 20 runs after the default priority of 10, so the post type and taxonomy are already registered. The reverse also works: a post type can declare its taxonomies through the taxonomies argument of register_post_type(). If you have not built the post type yet, follow how to create a custom post type in WordPress first.
Rewrite Rules and Flushing Permalinks
With pretty permalinks enabled, rewrite turns term archives into URLs like /cuisine/italian/. With 'hierarchical' => true inside the rewrite array, child terms include their parents, such as /cuisine/indian/south-indian/. with_front => false ignores any static prefix in your permalink structure, so a /blog/%postname%/ structure does not produce /blog/cuisine/italian/.
WordPress caches rewrite rules in the database. A brand-new taxonomy has no rules until they are regenerated, which is why new term archives return a 404 Not Found at first. Two ways to fix it:
- Manually: visit Settings → Permalinks and click Save Changes. You do not need to change anything.
- In code: flush once on plugin activation, never on every request.
<?php
// wp-content/plugins/recipe-taxonomies/recipe-taxonomies.php
function rt_activate() {
rt_register_cuisine_taxonomy();
rt_register_diet_taxonomy();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'rt_activate' );
function rt_deactivate() {
flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'rt_deactivate' );
The activation function calls the registration functions directly because the init hook has already fired by the time the activation hook runs. Calling flush_rewrite_rules() on init for every page load is expensive and is a common performance mistake.
Displaying Terms on the Front End
In a block theme
Block themes display terms with the Terms block, which is a variation of the Post Terms block (core/post-terms). Open a single template in the Site Editor, insert Cuisines, and it lists the current post's cuisines as links. It only offers your taxonomy if show_in_rest is enabled.
For term archives, create a template file in the theme. WordPress checks the template hierarchy in this order:
taxonomy-cuisine-italian.html(one specific term)taxonomy-cuisine.html(every term in the taxonomy)taxonomy.htmlarchive.htmlindex.html
Here is a minimal templates/taxonomy-cuisine.html with a Query Loop that inherits the archive's query:
<!-- wp-content/themes/my-theme/templates/taxonomy-cuisine.html -->
<!-- wp:template-part {"slug":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:query-title {"type":"archive"} /-->
<!-- wp:term-description /-->
<!-- wp:query {"query":{"inherit":true}} -->
<div class="wp-block-query">
<!-- wp:post-template -->
<!-- wp:post-featured-image {"isLink":true} /-->
<!-- wp:post-title {"isLink":true} /-->
<!-- wp:post-excerpt /-->
<!-- /wp:post-template -->
<!-- wp:query-pagination -->
<!-- wp:query-pagination-previous /-->
<!-- wp:query-pagination-numbers /-->
<!-- wp:query-pagination-next /-->
<!-- /wp:query-pagination -->
<!-- wp:query-no-results -->
<!-- wp:paragraph -->
<p>No recipes found for this cuisine yet.</p>
<!-- /wp:paragraph -->
<!-- /wp:query-no-results -->
</div>
<!-- /wp:query -->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer"} /-->
You can also create the same template visually under Appearance → Editor → Templates → Add New Template, where your taxonomy appears in the list once it is public.
In a classic theme
Classic themes use the same hierarchy with PHP files: taxonomy-cuisine-italian.php, taxonomy-cuisine.php, taxonomy.php, archive.php, then index.php. To print a post's terms inside the loop, use get_the_term_list():
<?php
// wp-content/themes/my-child-theme/template-parts/content.php
$cuisines = get_the_term_list( get_the_ID(), 'cuisine', '<p class="post-cuisines">Cuisine: ', ', ', '</p>' );
if ( $cuisines && ! is_wp_error( $cuisines ) ) {
echo wp_kses_post( $cuisines );
}
If you are editing a third-party theme, put template changes in a child theme so updates do not overwrite them, as explained in creating and customizing WordPress child themes.
Querying Posts by Custom Taxonomy
The tax_query argument of WP_Query handles everything from a single term to complex combinations. This query returns vegan Italian recipes, including posts in child cuisines of Italian:
<?php
// wp-content/themes/my-child-theme/page-vegan-italian.php
$recipes = new WP_Query(
array(
'post_type' => 'post',
'posts_per_page' => 12,
'tax_query' => array(
'relation' => 'AND',
array(
'taxonomy' => 'cuisine',
'field' => 'slug',
'terms' => array( 'italian' ),
'include_children' => true,
),
array(
'taxonomy' => 'diet',
'field' => 'slug',
'terms' => array( 'vegan' ),
),
),
)
);
if ( $recipes->have_posts() ) {
echo '<ul class="recipe-list">';
while ( $recipes->have_posts() ) {
$recipes->the_post();
printf(
'<li><a href="%s">%s</a></li>',
esc_url( get_permalink() ),
esc_html( get_the_title() )
);
}
echo '</ul>';
wp_reset_postdata();
}
Useful tax_query options:
field:term_id,slug,name, orterm_taxonomy_id. Slugs are stable and readable, so they are the best choice in code.operator:IN(default),NOT IN,AND(the post must have every listed term),EXISTS, andNOT EXISTS.include_children:trueby default for hierarchical taxonomies.
In a block theme, the Query Loop block offers the same filtering without code. Select the block, open Filters, and add Taxonomies. Every taxonomy registered with show_in_rest is listed.
Getting terms in PHP
A few functions cover nearly every need:
get_the_terms( $post_id, 'cuisine' )returns the terms attached to one post, with caching.get_terms( array( 'taxonomy' => 'cuisine', 'hide_empty' => true ) )returns all terms in a taxonomy.get_term_link( $term )returns a term's archive URL.wp_set_object_terms( $post_id, array( 'italian' ), 'cuisine', true )assigns terms in code; the last argument appends instead of replacing.
Always check the return value with is_wp_error(), because these functions return a WP_Error if the taxonomy does not exist.
Adding Term Meta
Terms can store their own metadata, such as a color, an icon, or a featured image ID for each cuisine. Register the meta key so it is sanitized and available over REST:
<?php
// wp-content/plugins/recipe-taxonomies/recipe-taxonomies.php
function rt_register_term_meta() {
register_term_meta(
'cuisine',
'accent_color',
array(
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_hex_color',
)
);
}
add_action( 'init', 'rt_register_term_meta' );
Read it with get_term_meta( $term_id, 'accent_color', true ) and write it with update_term_meta(). Adding a field to the term edit screen requires the {$taxonomy}_edit_form_fields and edited_{$taxonomy} hooks, or a fields plugin if you prefer not to write form code.
Managing Terms with WP-CLI
Large vocabularies are faster to manage from the command line. These are standard WP-CLI commands:
# Terminal
wp taxonomy list --fields=name,label,hierarchical,public
wp term create cuisine "Italian" --slug=italian
wp term create cuisine "South Indian" --slug=south-indian --parent=$(wp term get cuisine indian --by=slug --field=term_id)
wp term list cuisine --fields=term_id,name,slug,parent,count
wp post term add 123 diet vegan gluten-free
wp term delete diet keto --by=slug
For a full tour of what WP-CLI can do, see how to use WP-CLI to manage WordPress from the command line.
Registering Taxonomies Without Code
If you prefer a UI, plugins such as Custom Post Type UI and Advanced Custom Fields can register taxonomies from the admin. They call the same register_taxonomy() function with the settings you choose, and most can export the generated PHP. That export is a good way to learn the arguments, and moving it into your own plugin later removes the dependency.
Common Problems and Fixes
- The taxonomy does not appear in the block editor.
show_in_restis missing orfalse. Set it totrue. Also confirm the taxonomy is attached to the post type you are editing. - Term archives return 404. Rewrite rules were not flushed after registration. Save Settings → Permalinks once, or flush in an activation hook.
- Queries return nothing or the wrong posts. The taxonomy key collides with a reserved query variable such as
typeorauthor. Rename the key, or use a prefixed key with a clean rewrite slug. - Terms vanish after switching themes. The taxonomy was registered in the old theme's
functions.php. The terms are still in the database; move the registration into a plugin and they reappear. - The column or Quick Edit field is missing. Set
show_admin_column => trueand keepshow_uienabled. - Child term URLs are flat. Add
'hierarchical' => trueinside therewritearray, not just at the top level, then flush permalinks.
Custom Taxonomies FAQ
Use a plugin. Taxonomies describe your content, not its design, so they should survive a theme change. If you register them in a theme, switching themes hides the taxonomy and its term archives until you register it again, even though the terms remain in the database.
The block editor only shows taxonomies that are exposed to the REST API. Add show_in_rest set to true in the register_taxonomy arguments and make sure the taxonomy is attached to the post type you are editing.
A hierarchical taxonomy lets terms have parents and children and shows a checkbox list in the editor, like categories. A flat taxonomy has no parent relationships and shows a token field where editors type terms, like tags.
Yes. Pass an array of post types as the second argument to register_taxonomy, or attach the taxonomy later with register_taxonomy_for_object_type. Term counts and archives then include content from every attached post type.
There is no hard limit, but every taxonomy adds a panel to the editor and an admin screen. Create a taxonomy only when editors or visitors will actually group or filter content by it. Values that are unique per post belong in custom fields instead.
Term archives are indexable pages with their own URLs, so they can rank for category-style searches if they have enough content and a description. Thin archives with one or two posts can be set to noindex in your SEO plugin until they grow.
Conclusion
Custom taxonomies turn a messy category list into a clean content model. Register each one with register_taxonomy() on init, inside a plugin, with a short and non-reserved key. Set show_in_rest so it appears in the block editor, choose hierarchical based on the editing experience you want, add show_admin_column for quick scanning, and flush rewrite rules once on activation so archives work immediately.
From there, build archive templates through the template hierarchy, filter content with tax_query or the Query Loop block, store extra data in term meta, and manage large vocabularies with WP-CLI. The result is content that editors can organize consistently and visitors can browse the way they think.
Here are some useful references for going deeper on custom taxonomies:
- WordPress Developer Resources: register_taxonomy() — the full list of arguments and labels.
- WordPress Plugin Handbook: Working with Custom Taxonomies — registration, usage, and examples.
- WordPress Theme Handbook: Template Hierarchy — how taxonomy archive templates are chosen.
- WordPress Developer Resources: WP_Query taxonomy parameters — every tax_query option.
- WP-CLI Commands: wp term — create, list, update, and delete terms from the command line.


