How to display current and parent category of a post (WordPress snippet)

How to display current and parent category of a post (WordPress snippet)

Last verified: October 15, 2026
15 min read
Tutorial
Full-stack developer

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:

  1. Decide which category is “primary” when several are assigned.
  2. Walk parent until it is 0 to build the trail for that primary term.
  3. 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> &raquo; ';
		}
	}

	$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( ' &raquo; ', $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, ' &raquo; ' );
}

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; check is_wp_error().
  • Multisite / language plugins - archive links must respect the current site URL (get_category_link already does).
  • Block themes - prefer a shortcode or a small block that calls these helpers, rather than editing single.php only.
  • 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.

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

  1. get_the_category() gives terms; hierarchy lives in parent.
  2. One parent level: fetch $category->parent and link both.
  3. Full trail: walk parents with a while loop or use get_category_parents().
  4. Multiple categories: resolve primary via Yoast or Rank Math, then fall back.
  5. 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.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Want this implemented on your site?

If you want to convert the article into a working site improvement, redesign, or build plan, I can define the scope and implement it.

Related cluster

Explore other WordPress services and knowledge base

Strengthen your business with professional technical support in key areas of the WordPress ecosystem.

Frequently asked questions

Practical answers to apply the topic in real execution.

How long does it take to display parent and current category in WordPress#
About 10 to 15 minutes if you already know theme template files. Drop the snippet into single.php, a block theme pattern, or a shortcode wrapper.
What is the minimum WordPress version required for this snippet#
Any modern WordPress release works. get_the_category(), get_category() and get_category_link() have been in core for years. Use PHP 7.4 or newer.
What can go wrong when displaying the category hierarchy#
Assuming a post has only one category when WordPress allows many, and forgetting esc_html() or esc_url() on output. Both break markup or open XSS.
Should I use get_category_parents() instead of a custom loop#
For simple HTML trails, yes - it is built in and fast. Use a custom loop when you need Schema.org markup, custom separators, or primary-category selection.
How do I pick a primary category when a post has several#
Read Yoast WPSEO_Primary_Term or Rank Math post meta rank_math_primary_category. Fall back to the first term from get_the_category() if none is set.

Need an FAQ tailored to your industry and market? We can build one aligned with your business goals.

Let’s discuss

Related Articles