To filter WordPress posts by category, pass a category argument to WP_Query (cat for IDs, category_name for slugs, or tax_query for anything more precise), or hook pre_get_posts to change what an existing archive shows. The REST API takes ?categories=ID, the block editor’s Query Loop has a taxonomy filter in the sidebar, and a small shortcode or AJAX handler covers front end filter buttons. This guide walks through each method with code you can drop into a child theme or plugin.
new WP_Query(['category_name' => 'news', 'posts_per_page' => 10]) in a template for a custom list, use add_action('pre_get_posts', ...) with $query->set('cat', 12) to change the main blog loop, use /wp-json/wp/v2/posts?categories=12 for JavaScript, and use the Query Loop block’s Filters panel when you do not want to write PHP. Exclude a category by giving cat a negative ID, for example 'cat' => '-7'.Which method you pick depends on where the filtered list needs to appear and who maintains the site. Template code gives full control but requires a child theme; pre_get_posts is the correct way to bend the main query without duplicating it; the REST API and AJAX suit interactive filtering; the block editor is for site owners who never open a PHP file. If you need a scratch site to try these on, installing WordPress on a local host takes about ten minutes.
Find the category ID or slug first
Every method needs either the category’s numeric ID or its slug. In the dashboard go to Posts, then Categories, hover a category and read tag_ID= in the status bar URL, or open the category and read the same value in the address bar. The slug is shown in the table. In code, get_cat_ID('News') returns the ID for a name, and get_category_by_slug('news') returns the full term object. WP CLI users can run wp term list category --fields=term_id,name,slug.
Filter with WP_Query in a template
The WP_Query category parameters in the developer reference are the definitive list. The three you will use most are cat, category_name and tax_query.
<?php
// By ID (comma separated for OR, includes child categories)
$q = new WP_Query( array(
'cat' => '12,15',
'posts_per_page' => 10,
) );
// By slug (comma for OR, plus sign for AND)
$q = new WP_Query( array(
'category_name' => 'news+featured',
'posts_per_page' => 10,
) );
// Precise control with tax_query
$q = new WP_Query( array(
'post_type' => 'post',
'posts_per_page' => 10,
'tax_query' => array(
array(
'taxonomy' => 'category',
'field' => 'slug',
'terms' => array( 'news', 'reviews' ),
'operator' => 'IN',
'include_children' => false,
),
),
) );
if ( $q->have_posts() ) {
echo '<ul>';
while ( $q->have_posts() ) {
$q->the_post();
printf( '<li><a href="%s">%s</a></li>', esc_url( get_permalink() ), esc_html( get_the_title() ) );
}
echo '</ul>';
wp_reset_postdata();
}The differences are worth knowing. cat and category_name automatically include posts in child categories. tax_query lets you switch that off with include_children, combine multiple clauses with a relation of AND or OR, and use operators such as NOT IN and AND. Always call wp_reset_postdata() after a custom loop or the template tags that follow will read the wrong post.
| Parameter | Accepts | Children included | Best for |
|---|---|---|---|
cat | ID, comma list, negative ID to exclude | Yes | Quick include or exclude |
category_name | Slug, comma for OR, plus for AND | Yes | Readable template code |
category__in / category__not_in | Array of IDs | No | Exact category only |
category__and | Array of IDs | No | Posts in all listed categories |
tax_query | Nested array | Configurable | Multiple taxonomies, custom logic |
Change the main loop with pre_get_posts
When the goal is to alter what the home page, a category archive or search results show, do not write a second query in the template. Hook pre_get_posts and modify the main query before it runs. This keeps pagination, canonical URLs and theme templates working.
<?php
add_action( 'pre_get_posts', function ( $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// Hide the "Internal" category (ID 7) from the blog home page
if ( $query->is_home() ) {
$query->set( 'cat', '-7' );
}
// Only show "Tutorials" in search results
if ( $query->is_search() ) {
$query->set( 'category_name', 'tutorials' );
}
} );The two guards at the top matter. Without is_admin() you will filter the Posts screen in the dashboard, and without is_main_query() you will also filter every widget and sidebar query on the page, which is a classic source of “my related posts disappeared” bugs.
A shortcode for editors
A shortcode lets a content editor drop a filtered list anywhere in a post or page with [posts_by_category slug="news" count="5"]. Put this in a small plugin so it survives theme changes.
<?php
add_shortcode( 'posts_by_category', function ( $atts ) {
$a = shortcode_atts( array( 'slug' => '', 'count' => 5 ), $atts );
$q = new WP_Query( array(
'category_name' => sanitize_text_field( $a['slug'] ),
'posts_per_page' => absint( $a['count'] ),
'no_found_rows' => true,
) );
if ( ! $q->have_posts() ) {
return '<p>No posts found.</p>';
}
$out = '<ul class="posts-by-category">';
while ( $q->have_posts() ) {
$q->the_post();
$out .= sprintf( '<li><a href="%s">%s</a></li>', esc_url( get_permalink() ), esc_html( get_the_title() ) );
}
wp_reset_postdata();
return $out . '</ul>';
} );no_found_rows skips the count query that pagination needs, which is a free speed win for any list that does not paginate.
Filter through the REST API
Headless front ends, mobile apps and plain JavaScript widgets should use the REST API. The posts endpoint accepts categories (IDs, comma separated) and categories_exclude, documented in the REST API posts reference.
// Ten most recent posts in category 12, with featured images embedded
fetch('/wp-json/wp/v2/posts?categories=12&per_page=10&_embed')
.then(r => r.json())
.then(posts => {
const list = document.querySelector('#news');
list.innerHTML = posts.map(p => `<li><a href="${p.link}">${p.title.rendered}</a></li>`).join('');
});
// Everything except category 7
fetch('/wp-json/wp/v2/posts?categories_exclude=7');Only IDs are accepted here. If you have a slug, resolve it first with /wp-json/wp/v2/categories?slug=news and read the id field from the response.
Filter in the block editor with Query Loop
For block themes and anyone avoiding PHP, add a Query Loop block, choose a layout, then open the block settings sidebar. Turn off “Inherit query from template”, click the plus icon next to Filters and choose Taxonomies. Pick one or more categories from the Categories field and the block updates live. The same panel lets you set post count, order, sticky post behavior and an author filter. If the block sits in a category archive template you normally want “Inherit query from template” left on so the archive shows its own category, and only turn it off for lists that should ignore the current page context.
AJAX filter buttons on the front end
A row of buttons that swaps the post list without a page reload is the most requested version of this feature. The clean approach uses the REST API from the section above, so there is no admin-ajax.php handler to write and responses are cacheable. Print the buttons in PHP with the category IDs as data attributes, then handle clicks in JavaScript.
<?php
// In the template: one button per category
foreach ( get_categories( array( 'hide_empty' => true ) ) as $c ) {
printf( '<button class="cat-filter" data-cat="%d">%s</button>', $c->term_id, esc_html( $c->name ) );
}
echo '<ul id="filtered-posts"></ul>';
?>
<script>
document.querySelectorAll('.cat-filter').forEach(btn => {
btn.addEventListener('click', async () => {
const res = await fetch(`/wp-json/wp/v2/posts?categories=${btn.dataset.cat}&per_page=12`);
const posts = await res.json();
document.querySelector('#filtered-posts').innerHTML =
posts.map(p => `<li><a href="${p.link}">${p.title.rendered}</a></li>`).join('');
});
});
</script>Move the script into a file enqueued with wp_enqueue_script for production, and add a loading state so users see something while the request completes. If you prefer the classic admin-ajax.php route, register an action with wp_ajax_ and wp_ajax_nopriv_ hooks, verify a nonce, and return the rendered HTML with wp_send_json_success. The nonce and hook mechanics are the same ones used in sending mail in WordPress without a plugin, so that code is a good reference for the handler shape.
Excluding categories
Exclusion is easy to get wrong because the parameters behave differently. A negative cat value such as '-7' excludes that category and its children. category__not_in excludes only the exact IDs listed and leaves child categories alone. A tax_query clause with 'operator' => 'NOT IN' is the most explicit, and it is the only choice when you also need to exclude by tag or a custom taxonomy in the same query. To hide a category site wide, including feeds and search, combine the pre_get_posts example with a check that skips is_category() so the category’s own archive still works.
Performance
Category filtering itself is cheap because WordPress joins wp_term_relationships and wp_term_taxonomy on indexed columns. Problems come from the surrounding query. Set no_found_rows to true when you do not paginate, set update_post_meta_cache and update_post_term_cache to false for lists that only print titles and links, and cache the whole rendered list with get_transient and set_transient when it appears on every page. Avoid tax_query clauses with 'field' => 'name', which forces an extra lookup, and never filter in PHP after fetching everything; let MySQL do the work. Before changing queries on a production site, take a backup as described in how to back up a WordPress website manually.
Troubleshooting
The filtered list shows posts from other categories
Child categories are included by cat and category_name. Switch to category__in or a tax_query with 'include_children' => false if you want an exact match.
pre_get_posts broke the admin Posts screen or widgets
Add the is_admin() and is_main_query() guards shown above. Without them the hook fires on every query WordPress runs, including the dashboard list table and sidebar widgets.
Pagination returns 404 on page 2
A secondary WP_Query in a template does not know about the page URL. Pass 'paged' => get_query_var('paged', 1) to the query, or better, use pre_get_posts so the main query handles paging.
The REST call returns an empty array for a valid slug
The categories parameter needs a numeric ID. Resolve the slug through the categories endpoint first, or check that the posts are published, since drafts are hidden from unauthenticated requests.
Template tags after my loop show the wrong post
You forgot wp_reset_postdata(). Call it right after the custom loop finishes so the global post object returns to the main query’s current post.
Frequently asked questions
What is the difference between cat and category_name in WP_Query?
cat takes numeric category IDs, and category_name takes slugs. Both include posts from child categories, both accept comma separated lists for OR logic, and category_name also accepts a plus sign between slugs for AND logic. Slugs make template code easier to read, while IDs are stable if a slug ever changes.
How do I show posts from multiple categories at once?
Pass a comma separated list, such as 'cat' => '12,15' or 'category_name' => 'news,reviews', to match posts in any of them. To require posts to be in all listed categories, use category__and with an array of IDs, or a tax_query clause with the AND operator.
Can I filter posts by category without writing code?
Yes. In the block editor, insert a Query Loop block, disable “Inherit query from template”, add a Taxonomies filter and select the categories you want. Classic theme users can install a widget or filter plugin, but the Query Loop block covers most needs and ships with WordPress core.
How do I exclude a category from the home page?
Hook pre_get_posts, check is_home() and is_main_query(), then call $query->set('cat', '-7') with the ID of the category to hide. The negative sign means exclude. Place the code in a child theme’s functions file or a small custom plugin so updates do not overwrite it.
Does the REST API support filtering by category slug?
Not directly. The categories and categories_exclude parameters on the posts endpoint accept only numeric IDs. Request /wp-json/wp/v2/categories?slug=your-slug first, read the id from the response, and use that ID in the posts request.
The bottom line
WordPress gives you several correct ways to filter posts by category, and the right one depends on where the list lives. Use WP_Query in templates and shortcodes, pre_get_posts for the main loop, the REST API for JavaScript, and the Query Loop block when no code is wanted.
Whichever route you choose, remember that cat and category_name include child categories, always reset post data after a custom loop, and keep queries lean with no_found_rows and transients when the list appears on busy pages.

