The standard WordPress function the_category() is great, but it has one flaw: it always generates HTML links (<a href="...">...</a>) to the archive page. What if you’re building a custom layout (like a portfolio card or slider) where the category should be plain text, not a clickable element?
Learn more about professional WordPress development at WPPoland. The solution is to use get_the_category(), which returns an array of objects instead of ready-made HTML.
Get post category name without a link in PHP
Here’s a ready-to-use snippet you can paste into your single.php or content.php:
<?php
// Get all categories assigned to the current post
$categories = get_the_category();
if ( ! empty( $categories ) ) {
// Display the name of the first category found
echo esc_html( $categories[0]->name );
}
?>How to display categories as a comma-separated list
If your post has multiple categories and you want to list them as comma-separated text:
<?php
$categories = get_the_category();
$output = array();
if ( ! empty( $categories ) ) {
foreach ( $categories as $category ) {
// Add name to the array
$output[] = esc_html( $category->name );
}
// Join the array into a string with a separator
echo implode( ', ', $output );
}
?>What get_the_category() returns besides the name
This function gives you access to the full category object. Besides the name (->name), you can extract:
->slug(useful for CSS classes, e.g.,<span class="cat-<?php echo $cat->slug; ?>">)->term_id(category ID)->description(category description)->count(number of posts in this category)
What get_the_category does inside WordPress core
In the wordpress-develop source, get_the_category() is a thin wrapper. It calls get_the_terms( $post_id, 'category' ), turns a false or WP_Error result into an empty array, reindexes it with array_values(), runs every term through the private helper _make_cat_compat() and returns the result through the get_the_categories filter. Three practical consequences follow from those few lines.
Legacy properties exist only on this function’s output. _make_cat_compat() copies the modern fields into old names: cat_ID, cat_name, category_nicename, category_count, category_description and category_parent. Objects from get_the_terms() do not get them. A template that reads $cat->cat_name works today and returns nothing the day someone swaps the call for get_the_terms() to support a custom taxonomy. Read name, slug and term_id, which both functions share.
The order is alphabetical, not editorial. get_the_terms() first checks the post’s term relationship cache and otherwise calls wp_get_object_terms(), which runs a WP_Term_Query whose default orderby is name. So $categories[0] is the alphabetically first category, unless a filter reorders the array. It is never the primary category chosen in an SEO plugin.
Plugins can rewrite the array. Any callback on get_the_categories (with the $post_id argument since WordPress 4.4) or on get_the_terms can add, remove or reorder terms before your template sees them. When a badge shows a category the editor never assigned, list the callbacks on those two hooks before you debug the template itself.
How to show the category as a badge on blog cards
A common use case is styling labels on blog cards.
// Inside the WordPress loop
$cats = get_the_category();
$first_cat = !empty($cats) ? $cats[0] : null;
if ($first_cat) : ?>
<span class="badge badge-<?php echo esc_attr($first_cat->slug); ?>">
<?php echo esc_html($first_cat->name); ?>
</span>
<?php endif; ?>This way, if you have a category “News”, you’ll get the class .badge-news, which you can easily color in CSS. This level of control is something the_category() simply doesn’t offer.
Which WordPress functions return a category name
WordPress ships with several functions that hand you a category name without wrapping it in an anchor tag. Which one you pick depends on the context: a single post inside the loop, an archive page, or a spot where you only have a category ID.
get_the_category() with the first element. This is the approach from the snippet above and the best default inside the loop. It returns an array of WP_Term objects for the current post, so $categories[0]->name gives you the first name as a string, with the slug, ID and description one arrow away if you need them later.
get_cat_name() when you already have an ID. If the category ID is stored somewhere (a theme option, a custom field, a widget setting), you can skip the array entirely:
<?php
// Returns the name for category ID 12, or an empty string if it does not exist
echo esc_html( get_cat_name( 12 ) );
?>single_cat_title() on archive pages. On a category archive (category.php), the category you care about is not attached to one post, it is the queried object of the whole page. get_the_category() is the wrong tool there. single_cat_title() reads the queried object directly, and passing false as the second argument makes it return the value instead of echoing it:
<?php
if ( is_category() ) {
$name = single_cat_title( '', false );
echo esc_html( $name );
}
?>get_the_terms() vs get_the_term_list(). These two are confused constantly. get_the_term_list() builds ready-made HTML with links, which is exactly what this article is trying to avoid. get_the_terms() returns raw WP_Term objects, so it gives you the same control as get_the_category() and additionally accepts any taxonomy name:
<?php
// get_the_term_list() would print linked HTML. Use get_the_terms() instead:
$terms = get_the_terms( get_the_ID(), 'category' );
if ( $terms && ! is_wp_error( $terms ) ) {
echo esc_html( $terms[0]->name );
}
?>Rule of thumb: reach for get_the_terms() when the code might switch to a custom taxonomy later, and get_the_category() when the template is category-only and you want the shortest call.
Get category names as an array with wp_get_post_terms
wp_get_post_terms() passes its third argument to wp_get_object_terms(), so it accepts the WP_Term_Query options that get_the_terms() does not. With 'fields' => 'names' it returns a plain array of strings and the loop disappears:
<?php
// Category names only, most used first
$names = wp_get_post_terms(
get_the_ID(),
'category',
array(
'fields' => 'names',
'orderby' => 'count',
'order' => 'DESC',
)
);
if ( ! is_wp_error( $names ) && ! empty( $names ) ) {
echo esc_html( implode( ', ', $names ) );
}
?>The cost is caching. get_the_terms() reads the per-post relationship cache first, and the main WP_Query fills that cache for every post on the page because update_post_term_cache defaults to true. wp_get_post_terms() skips that cache and always calls wp_get_object_terms(), so on an archive with twenty cards it runs twenty term queries for data WordPress already loaded. Inside the loop, wp_list_pluck( get_the_category(), 'name' ) gives the same array of names from cached data. Keep wp_get_post_terms() for places where you need its arguments, such as ordering by count in a sidebar or a REST callback that handles one post at a time.
How to sort and exclude post categories
The comma-separated loop earlier in the article covers the basic case. In real templates you usually need two more things: control over the order and a way to skip categories you never want printed (the classic example is “Uncategorized” leaking into a card design).
For ordering, wp_list_pluck() shortens the loop to one line and plain sort() handles alphabetical output. WordPress returns categories ordered by name by default, but plugins can filter that, so sorting explicitly costs nothing and removes a surprise:
<?php
$categories = get_the_category();
if ( ! empty( $categories ) ) {
// Pull just the names out of the term objects
$names = wp_list_pluck( $categories, 'name' );
// Explicit alphabetical order, independent of any filters
sort( $names );
echo esc_html( implode( ' / ', $names ) );
}
?>To exclude specific categories, compare against the slug rather than the name. Slugs are stable, while names get edited by clients without warning:
<?php
$categories = get_the_category();
$skip = array( 'uncategorized', 'featured' );
$names = array();
foreach ( $categories as $category ) {
if ( in_array( $category->slug, $skip, true ) ) {
continue;
}
$names[] = $category->name;
}
if ( ! empty( $names ) ) {
echo esc_html( implode( ', ', $names ) );
}
?>Note that escaping happens once, on the final joined string. Escaping each name and then imploding also works, but keeping a single esc_html() at the output point makes it obvious during code review that nothing leaves the template unescaped.
Get a custom taxonomy term name without a link
Everything above extends to custom taxonomies with one function swap. get_the_terms() takes the post ID and the taxonomy slug, so the same pattern works for a portfolio “project type”, a WooCommerce product_cat, or any taxonomy registered by your theme:
<?php
$terms = get_the_terms( get_the_ID(), 'project_type' );
if ( $terms && ! is_wp_error( $terms ) ) {
$names = wp_list_pluck( $terms, 'name' );
echo esc_html( implode( ', ', $names ) );
}
?>The is_wp_error() check is not optional decoration. get_the_terms() returns three different types: an array of terms on success, false when the post has no terms, and a WP_Error object when the taxonomy does not exist (a typo in the slug, or a plugin that registers the taxonomy being deactivated). A WP_Error is truthy, so an if ( $terms ) check alone lets it through and the foreach then throws a warning on every page load. The combined $terms && ! is_wp_error( $terms ) condition covers all three cases in one line.
Get the primary category from Yoast and Rank Math
On blog cards and breadcrumbs, the business usually wants one label: the category the editor marked as primary, not the alphabetically first one. Yoast SEO stores that choice as a term ID in the post meta key _yoast_wpseo_primary_category, and Rank Math uses rank_math_primary_category. Without either plugin, define your own meta or document in the theme README that the first category wins.
<?php
/**
* Primary category term, or the first one from get_the_category().
*/
function wppoland_get_primary_category( $post_id = 0 ) {
$post_id = $post_id ? (int) $post_id : get_the_ID();
$primary_id = (int) get_post_meta( $post_id, '_yoast_wpseo_primary_category', true );
if ( ! $primary_id ) {
$primary_id = (int) get_post_meta( $post_id, 'rank_math_primary_category', true );
}
if ( $primary_id ) {
$term = get_term( $primary_id, 'category' );
if ( $term && ! is_wp_error( $term ) ) {
return $term;
}
}
$categories = get_the_category( $post_id );
return ! empty( $categories ) ? $categories[0] : null;
}
$primary = wppoland_get_primary_category();
if ( $primary ) {
echo esc_html( $primary->name );
}
?>Keep the fallback: if the primary term was deleted but the meta stayed, get_term() fails and the function returns the first category instead of an empty badge. Use the same helper for the card, the breadcrumb and any schema output, so all three show the same category.
How to escape category names and fix double-encoded ampersands
Every snippet here wraps output in esc_html(), and that habit matters more with category names than you might expect. Category names are user-entered content: an editor can create a category called Tips & Tricks or paste in a name containing angle brackets. Without escaping, the ampersand produces invalid HTML, and anything bracket-shaped becomes a live tag in your template.
There is one subtlety: WordPress stores some characters as entities. Before a term name is saved, default-filters.php runs it through sanitize_text_field(), wp_filter_kses() and, at priority 30, _wp_specialchars() on the pre_term_name hook. Tags are stripped at save time, and a category named Tips & Tricks sits in the database as Tips & Tricks.
Inside HTML that is harmless. esc_html() calls _wp_specialchars() with double encoding switched off, so an existing & stays & and the browser shows a plain ampersand. The literal & appears outside HTML: in JSON-LD built with wp_json_encode(), in a REST field that JavaScript writes with textContent, in a plain-text email subject or a CSV export. A plain PHP htmlspecialchars() call, which double-encodes by default, produces the visible &amp;. For those contexts decode first, then apply the escaping of the target format:
<?php
$categories = get_the_category();
if ( ! empty( $categories ) ) {
// Turn the stored "&" back into "&" for a non-HTML context
$name = wp_specialchars_decode( $categories[0]->name, ENT_QUOTES );
$schema = array( 'articleSection' => $name );
echo wp_json_encode( $schema );
}
?>For multilingual sites, plain-text category output plays well with translation plugins because tools like Polylang and WPML filter the term query itself: get_the_category() and get_the_terms() already return the name for the current language, so no extra translation call is needed in the template.
How to show the category name in the block editor with a shortcode
Classic template tags do not help inside the block editor, patterns, or a page builder text widget. The cheapest bridge is a tiny shortcode that returns the plain category name, registered in functions.php or a small site plugin:
<?php
add_shortcode( 'plain_category', function () {
$categories = get_the_category();
if ( empty( $categories ) ) {
return '';
}
return esc_html( $categories[0]->name );
} );
?>Drop [plain_category] into a Shortcode block, a synced pattern, or a query loop template and it prints the first category of the current post as text. Two details are easy to get wrong here. First, a shortcode callback must return its output, never echo it: echoing makes the text appear at the top of the content instead of where the shortcode sits. Second, inside a Query Loop block the global post is set per item, so the shortcode picks up the right category for each post in the loop without extra work.
The core Post Terms block is not an alternative here. Its block.json defines only the term, separator, prefix and suffix attributes, and its PHP render callback builds the output with get_the_term_list(), so every term it prints is a link. There is no setting that turns the links off. CSS such as pointer-events: none only hides the click: the markup is still an anchor, and screen readers still announce a link.
Why not use strip_tags on the_category
This shortcut still turns up in old themes and forum answers:
<?php
// Anti-pattern: do not do this.
echo strip_tags( get_the_category_list( ', ' ) );
?>The concrete problems:
- Wasted work. WordPress builds the full HTML with links,
reland URLs only for you to throw the tags away. - Fragile separators. If the theme or a filter on
the_categoryadds wrappers,strip_tags()can glue names together without a space. - No escaping. After
strip_tags()you still needesc_html(), and many people forget it. - You lose the data. Without the object you have no
slugorterm_idfor CSS classes or schema, so you end up going back toget_the_category()anyway.
Keep strip_tags() for rich content of unknown origin, not for removing anchors you asked the API for yourself.
How to cache the category name in WordPress
get_the_category() and get_the_terms() read the relationship between the post and its terms plus the WP_Term objects. With a persistent object cache (Redis or Memcached through a drop-in), terms and relationships get cached without extra code. In practice:
- Do not create a transient per card just for the category name in the main loop: the cost of
get_the_category()is low next to the rest of the template. - If you cache whole HTML fragments (cards in a transient or at the edge), the name lives inside the fragment, and the fragment is invalidated when the post is saved (
save_post) or the term is edited (edited_term). - If your own index (Elasticsearch, a custom table) stores only the
term_id, resolve thenameat render time withget_term(). That way renaming a category updates the archive without rebuilding the index.
<?php
add_action(
'save_post_post',
function ( $post_id ) {
delete_transient( 'wppoland_card_html_' . (int) $post_id );
}
);
?>When to use the_category with links
Some screens want the linked markup: the classic “Posted in News, Events” line under a single post, a post meta widget where each category should be clickable, or a minimal theme without cards where category archives are the secondary navigation. There, call the_category( ', ' ) or use the Post Terms block and do not rebuild the markup by hand. If the design later needs a link around a single badge, wrap the escaped name in <a href="<?php echo esc_url( get_category_link( $term->term_id ) ); ?>"> instead of going back to the_category(). The mistake is mixing both modes in one component without a rule, so write the rule down in the theme: badges are text, meta lines are links.
Why get_the_category returns empty or wrong categories
Reaching for the_category() and trying to strip the links afterwards. the_category() always prints linked markup, and there is no parameter to turn that off. Post-processing its output with strip_tags() technically works but is fragile and slower than asking for the raw data in the first place. If you need text, start from get_the_category() or get_the_terms().
Forgetting wp_reset_postdata() in custom loops. get_the_category() reads the global $post. In a custom WP_Query loop that calls the_post(), the global stays pointed at the last item of the custom loop unless you reset it, so any category call after the loop reports the wrong post’s categories:
<?php
$related = new WP_Query( array( 'posts_per_page' => 3 ) );
while ( $related->have_posts() ) {
$related->the_post();
$cats = get_the_category();
// ... output ...
}
// Without this, get_the_category() below refers to the last related post
wp_reset_postdata();
?>Assuming a post always has a category. Regular posts get “Uncategorized” by default, but custom post types with category support can genuinely have none, and code that blindly reads $categories[0] then throws an undefined index notice. The ! empty( $categories ) guard used throughout this article is there for a reason: keep it even when “every post has a category” seems guaranteed today.







