Custom themes often need more than a single category name. Breadcrumbs, archive headers, and related-post filters all want the parent as well as the leaf term. WordPress stores categories as a hierarchy in the term_taxonomy table, but get_the_category() returns a flat array. You walk the parent property yourself.
This tutorial covers a one-level parent/child link, a full ancestor trail, primary-category selection with common SEO plugins, and optional BreadcrumbList JSON-LD. All output is escaped.
For broader theme and plugin work: WordPress developer.
How WordPress stores the category hierarchy
Every term has a row in wp_terms and a taxonomy row in wp_term_taxonomy with a parent column. A value of 0 means the term is a root. Deeper levels are plain chains of integer IDs, not nested sets, so a loop or get_ancestors() is all you need to walk them.
Archive links come from get_category_link() or get_term_link(). Hardcoded paths such as /category/foo/ break as soon as someone renames the category base in the permalink settings or a language plugin changes the URL structure. The link helpers follow those settings; a hardcoded string does not.
Why get_the_category() is not enough alone
get_the_category() returns WP_Term objects for the current post (or a given post ID). Each object has term_id, name, slug, and parent. The array is sorted by name (core fills the term cache with orderby => name), not by hierarchy, so a child can appear before its parent. If a post sits in Technology / WordPress / Performance, you may get three terms and still not know which one is the deepest leaf for a breadcrumb.
Practical rules:
- Decide which category is “primary” when several are assigned.
- Walk
parentuntil it is0to build the trail for that primary term. - Never echo names or URLs without
esc_html()/esc_url().
Exclude Uncategorized from category breadcrumbs
New installs attach posts to the default category. When editors later move a post into a real category, the default often stays as a second assignment. Because the array is sorted by name, a breadcrumb that takes $categories[0] can then fall back to “Uncategorized”. Filter out the term stored in get_option( 'default_category' ) before you pick the leaf, or require a primary category in the editorial workflow.
Show the current category and its parent category
Paste into a child theme functions.php or a small site-specific plugin:
function wppoland_get_category_hierarchy() {
$categories = get_the_category();
if ( empty( $categories ) ) {
return false;
}
$category = $categories[0];
$output = '';
if ( $category->parent ) {
$parent = get_category( $category->parent );
if ( $parent && ! is_wp_error( $parent ) ) {
$output .= '<a href="' . esc_url( get_category_link( $parent->term_id ) ) . '">' . esc_html( $parent->name ) . '</a> » ';
}
}
$output .= '<a href="' . esc_url( get_category_link( $category->term_id ) ) . '">' . esc_html( $category->name ) . '</a>';
return $output;
}How to use the function in a theme template
<?php
$hierarchy = wppoland_get_category_hierarchy();
if ( $hierarchy ) {
echo '<nav class="category-breadcrumb" aria-label="Category">' . $hierarchy . '</nav>';
}
?>Example output: Technology » WordPress
This is enough for two-level trees. For deeper trees, use the full trail below.
Show the current category on a category archive page
On a category archive, get_the_category() does not help: each post in the list can carry several categories, and none of them has to be the archive being viewed. The archive’s category is the queried object. is_category() confirms the context, get_queried_object() returns the archive’s WP_Term, and get_queried_object_id() returns only its ID.
if ( is_category() ) {
$term = get_queried_object();
foreach ( array_reverse( get_ancestors( $term->term_id, 'category', 'taxonomy' ) ) as $ancestor_id ) {
$ancestor = get_term( $ancestor_id, 'category' );
if ( $ancestor && ! is_wp_error( $ancestor ) ) {
printf(
'<a href="%s">%s</a> <span aria-hidden="true">/</span> ',
esc_url( get_term_link( $ancestor ) ),
esc_html( $ancestor->name )
);
}
}
echo '<h1>' . esc_html( $term->name ) . '</h1>';
}The trail above the heading is built from the archive term itself, so a post filed in three branches cannot shift it. For the heading alone, single_cat_title() prints the name of the current category archive.
List the subcategories of the current category
The opposite direction from the parent is the list of children. wp_list_categories() accepts child_of (all descendants of a term) and depth (how many levels to print). An empty title_li removes the list item with the default “Categories” heading, so you control the ul yourself.
if ( is_category() ) {
echo '<ul class="subcategories">';
wp_list_categories(
array(
'child_of' => get_queried_object_id(),
'depth' => 1,
'title_li' => '',
)
);
echo '</ul>';
}Two details from the defaults: hide_empty is 1, so empty subcategories are missing until you pass 'hide_empty' => 0. And child_of without depth prints the whole subtree, not only the direct level. If you need data rather than HTML, get_categories( array( 'parent' => $id ) ) returns only direct children, while get_term_children( $id, 'category' ) returns the IDs of all descendants at every level.
Check if a post is in a child category
in_category() goes through has_category() to is_object_in_term(), and that function compares only the terms assigned to the post directly. A post filed only under WordPress returns false for in_category( 'technology' ) even when WordPress is a child of Technology. For conditions such as “anywhere in this branch”, check the ancestors as well:
/**
* True if the post is in $parent_id or in any of its descendants.
*/
function wppoland_in_category_branch( $parent_id, $post_id = false ) {
$parent_id = (int) $parent_id;
foreach ( get_the_category( $post_id ) as $term ) {
if ( $term->term_id === $parent_id || cat_is_ancestor_of( $parent_id, $term ) ) {
return true;
}
}
return false;
}cat_is_ancestor_of() calls term_is_ancestor_of() with the category taxonomy and climbs the parents recursively. For custom taxonomies, call term_is_ancestor_of( $parent, $child, $taxonomy ) directly. The same check is useful in sidebar menus, where it stops a parent item from losing its active state when the reader is on a grandchild archive.
Get all parent categories with a while loop
function wppoland_get_full_category_trail( $category_id ) {
$trail = array();
while ( $category_id ) {
$category = get_category( $category_id );
if ( ! $category || is_wp_error( $category ) ) {
break;
}
array_unshift( $trail, $category );
$category_id = (int) $category->parent;
}
$parts = array();
foreach ( $trail as $cat ) {
$parts[] = '<a href="' . esc_url( get_category_link( $cat->term_id ) ) . '">' . esc_html( $cat->name ) . '</a>';
}
return implode( ' » ', $parts );
}Call it with the primary category ID:
$cats = get_the_category();
if ( ! empty( $cats ) ) {
echo wppoland_get_full_category_trail( $cats[0]->term_id );
}array_unshift keeps the order from root to leaf. The loop stops when parent is 0.
Category breadcrumb with get_category_parents()
Core already ships a helper:
$cats = get_the_category();
if ( ! empty( $cats ) ) {
echo get_category_parents( $cats[0]->term_id, true, ' » ' );
}Arguments: term ID, whether to link names, separator. Good for classic themes. Less flexible when you need Schema, custom classes, or to skip the leaf.
Use get_term_parents_list() instead of get_category_parents()
Since WordPress 4.8.0, get_category_parents() is only a wrapper: it calls get_term_parents_list() with the category taxonomy. The same function works with any hierarchical taxonomy, including WooCommerce’s product_cat. Its arguments are format (name or slug), separator, link (default true) and inclusive (default true, which adds the term itself at the end). The fifth argument of get_category_parents() is $deprecated: it has not been used since 4.8.0 and triggers a deprecation notice if you pass anything.
$list = get_term_parents_list(
$term_id,
'product_cat',
array(
'inclusive' => false,
'separator' => ' / ',
)
);
if ( $list && ! is_wp_error( $list ) ) {
echo wp_kses_post( rtrim( $list, ' /' ) );
}The source in category-template.php shows two quirks. The term name goes into the link without esc_html(); only the URL passes through esc_url(). And the separator is appended after every item, including the last one, which is why the example calls rtrim(). Pass 'inclusive' => false to leave out the current term, for example when it already appears as the heading below. Protection against a broken parent chain (a term that points back to one of its own descendants after a bad import) comes from get_ancestors(), which stops as soon as a parent ID repeats.
Get the primary category from Yoast or Rank Math
Editors often assign several categories. Breadcrumbs should follow one chain.
Yoast SEO
function wppoland_get_primary_category( $post_id = null ) {
$post_id = $post_id ?: get_the_ID();
if ( class_exists( 'WPSEO_Primary_Term' ) ) {
$primary = new WPSEO_Primary_Term( 'category', $post_id );
$term_id = $primary->get_primary_term();
if ( $term_id && ! is_wp_error( $term_id ) ) {
$term = get_category( $term_id );
if ( $term && ! is_wp_error( $term ) ) {
return $term;
}
}
}
$categories = get_the_category( $post_id );
return ! empty( $categories ) ? $categories[0] : null;
}Rank Math
function wppoland_get_rankmath_primary_category( $post_id = null ) {
$post_id = $post_id ?: get_the_ID();
$term_id = (int) get_post_meta( $post_id, 'rank_math_primary_category', true );
if ( $term_id ) {
$term = get_category( $term_id );
if ( $term && ! is_wp_error( $term ) ) {
return $term;
}
}
$categories = get_the_category( $post_id );
return ! empty( $categories ) ? $categories[0] : null;
}Wire primary selection into the trail function so archives, related posts, and breadcrumbs agree on the same path.
Add BreadcrumbList schema for category breadcrumbs
Visible breadcrumbs help humans. Matching structured data helps search engines understand the path. Emit JSON-LD once in wp_head on singles:
function wppoland_schema_category_breadcrumbs() {
if ( ! is_singular( 'post' ) ) {
return;
}
$category = wppoland_get_primary_category();
if ( ! $category ) {
return;
}
$trail = array();
$current = $category;
while ( $current ) {
array_unshift( $trail, $current );
$current = $current->parent ? get_category( $current->parent ) : null;
if ( $current && is_wp_error( $current ) ) {
$current = null;
}
}
$items = array();
$position = 1;
$items[] = array(
'@type' => 'ListItem',
'position' => $position++,
'name' => 'Home',
'item' => home_url( '/' ),
);
foreach ( $trail as $cat ) {
$items[] = array(
'@type' => 'ListItem',
'position' => $position++,
'name' => $cat->name,
'item' => get_category_link( $cat->term_id ),
);
}
$items[] = array(
'@type' => 'ListItem',
'position' => $position,
'name' => get_the_title(),
'item' => get_permalink(),
);
$schema = array(
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => $items,
);
echo '<script type="application/ld+json">' . wp_json_encode( $schema ) . '</script>' . "\n";
}
add_action( 'wp_head', 'wppoland_schema_category_breadcrumbs' );If your theme or SEO plugin already outputs BreadcrumbList, skip this to avoid duplicate graphs.
What Google shows from BreadcrumbList
Google’s breadcrumb documentation sets the minimum: a BreadcrumbList with at least two ListItem entries, position starting at 1, and an item URL on every element. The exception is the last element: if item is missing there, Google uses the URL of the page itself. For a post, home plus one category already meets the minimum; the post title as the final element is allowed but not required.
Google allows several breadcrumb trails for one page and recommends a trail that reflects a typical user path rather than one that mirrors the URL structure. For category breadcrumbs that is an argument for the primary category: it describes the path editors intended, even when the permalink contains no category at all.
The trail is now visible in Google Search on desktop only. Since 23 January 2025, mobile results show only the domain in the URL line, as announced on the Search Central blog. The markup is still supported and the Search Console report remains. The visible breadcrumbs in the theme serve readers first anyway.
Parent terms in custom taxonomies
Categories are one hierarchical taxonomy. For a custom taxonomy, use get_the_terms( $post_id, 'your_tax' ) and get_term_link(). Parent walking is the same via $term->parent. Do not call get_category() on non-category terms.
Category breadcrumb edge cases to test
- Post with no categories - return early; do not print empty nav.
- Orphaned parent ID (deleted parent) -
get_category()can return an error; checkis_wp_error(). - Multisite / language plugins - archive links must respect the current site URL (
get_category_linkalready does). - Block themes - prefer a shortcode or a small block that calls these helpers, rather than editing
single.phponly. - Deleted primary term ID left in Yoast or Rank Math meta - fall back to
get_the_category(). - Category name with quotes or ampersands - always
esc_html(). - Import from CSV or an old CMS - parent IDs can point to deleted terms; stop the trail instead of logging notices.
- Hierarchy moved after publishing - clear any cached trail for the old and the new path.
Write these as PHPUnit or integration tests if the theme has CI. At least once by hand: a post with two categories, a primary set, a parent three levels deep, and an empty object cache.
Breadcrumbs block in WordPress 7.0 with a primary category
Since WordPress 7.0, core includes its own core/breadcrumbs block (the current release according to the WordPress.org version API is 7.1.3). Its block.json lists the attributes separator (default /), showHomeItem, showCurrentItem, showOnHomePage and prefersTaxonomy. On a post without a parent post, the block builds the trail from taxonomy terms: it takes the first taxonomy registered for the post type that has terms, and from it the first term returned by get_the_terms(), via reset( $terms ). That is the same trap as $categories[0]: without intervention, the alphabetically first name wins.
The block’s source provides the filter block_core_breadcrumbs_post_type_settings for this. It expects an array with taxonomy and term, and term is a slug, not an ID. This passes the primary category from Yoast SEO or Rank Math to the block:
add_filter(
'block_core_breadcrumbs_post_type_settings',
static function ( $settings, $post_type, $post_id ) {
if ( 'post' !== $post_type ) {
return $settings;
}
$primary = (int) get_post_meta( $post_id, '_yoast_wpseo_primary_category', true );
if ( ! $primary ) {
$primary = (int) get_post_meta( $post_id, 'rank_math_primary_category', true );
}
$term = $primary ? get_term( $primary, 'category' ) : null;
if ( $term && ! is_wp_error( $term ) ) {
$settings['taxonomy'] = 'category';
$settings['term'] = $term->slug;
}
return $settings;
},
10,
3
);A second filter, block_core_breadcrumbs_items, lets you change the final list of label and url pairs before rendering. The block’s markup already covers the accessibility basics: a nav with aria-label, an ol, and the last item as a span with aria-current="page". The block does not print JSON-LD. BreadcrumbList still comes from the SEO plugin or from a function like the one above, and both should use the same term you pass to the block through the filter.
Category breadcrumb performance and caching
Category lookups are cheap and object-cached in normal WordPress requests. Avoid calling the full trail builder dozens of times in a loop of posts; compute once per post or store a transient if you do heavy Schema work on archive pages.
Get post categories outside the Loop by post ID
All of the helpers above assume the global post is set (inside the Loop, or after setup_postdata). On widgets, REST callbacks, and CLI scripts, pass an explicit ID:
function wppoland_get_category_hierarchy_for_post( $post_id ) {
$categories = get_the_category( $post_id );
if ( empty( $categories ) ) {
return false;
}
$primary = $categories[0];
if ( function_exists( 'wppoland_get_primary_category' ) ) {
$maybe = wppoland_get_primary_category( $post_id );
if ( $maybe ) {
$primary = $maybe;
}
}
return wppoland_get_full_category_trail( $primary->term_id );
}This matters on archive cards where you loop many posts: never call get_the_category() without the current card’s ID after the Loop advances.
Accessible category breadcrumb markup
Use a <nav> with an accessible name (aria-label="Category" or a translated string). Keep the visible separator (» or /) outside the link text so screen readers do not hear “Technology right-pointing double angle quotation mark WordPress” as one run-on phrase - putting separators between anchors (as in the implode version) is clearer.
Do not rely on color alone to show the current crumb. The last item can be plain text without a link if you are already on that archive; on single posts, linking the leaf category to its archive is usually what editors expect.
Category breadcrumbs with Polylang and WPML
On multilingual sites, get_category_link() returns the URL for the term in the current language when the translation plugin is configured correctly. Still verify that primary-category meta from Yoast/Rank Math is per-language (or duplicated) after a translation workflow. A Polish primary term ID on an English post is a common migration bug.
How to avoid duplicate breadcrumbs and BreadcrumbList markup
If the site’s IA is driven by custom taxonomies, landing pages, or a manual breadcrumb plugin, do not also emit a second category trail. Duplicate BreadcrumbList JSON-LD and conflicting visible crumbs confuse both users and rich-result validators. Pick one source of truth per template.
Summary
get_the_category()gives terms; hierarchy lives inparent.- One parent level: fetch
$category->parentand link both. - Full trail: walk parents with a while loop or use
get_category_parents(). - Multiple categories: resolve primary via Yoast or Rank Math, then fall back.
- Escape every name and URL; optionally emit BreadcrumbList once.
Need this wired into a production theme or a block pattern? Contact WPPoland with the theme slug and whether you use classic or block templates.







