Extracting post lists from categories in WordPress, developer guide

Extracting post lists from categories in WordPress, developer guide

Last verified: October 12, 2026
17 min read
Tutorial
500+ WP projects
Full-stack developer

#How to get posts by category in WordPress

Learn more about professional WordPress development at WPPoland. One of the most common tasks in WordPress development is retrieving posts from specific categories. Whether you’re building a custom homepage layout, creating a category archive template, or displaying related content, understanding how to efficiently query posts by category is essential for any WordPress developer.

This guide covers practical approaches to extracting post lists from categories, from simple implementations to advanced optimization techniques. By the end, you’ll have a complete toolkit for handling category-based queries in any WordPress project.

#How WordPress stores post categories

Before diving into code, it’s important to understand how WordPress handles categories:

  • Categories are a built-in taxonomy in WordPress
  • Each post can belong to multiple categories
  • Categories can be hierarchical (parent/child relationships)
  • Category data is stored in the wp_terms and wp_term_taxonomy tables
  • Post-category relationships are stored in wp_term_relationships

Understanding this structure helps you write more efficient queries and troubleshoot issues when they arise.

#Finding a category ID from a slug or name

Most query arguments accept either a slug or a numeric ID, but ID-based arguments (cat, category__in) are the fastest because they skip a term lookup. When you only know the slug, resolve it once and reuse the ID:

// From a slug
$term = get_category_by_slug('news');
$news_id = $term ? $term->term_id : 0;

// From a display name
$news_id = get_cat_ID('News'); // returns 0 if not found

// From any taxonomy (categories included)
$term = get_term_by('slug', 'news', 'category');

Cache the resolved ID in a constant or option if you hard-code it in a template; calling get_cat_ID() on every request adds an avoidable query.

#Get posts by category with WP_Query

WP_Query is WordPress’s primary class for querying posts. It offers maximum flexibility and is the recommended approach for most use cases.

#Query by category slug

$args = array(
    'category_name' => 'news',
    'posts_per_page' => 10,
    'orderby' => 'date',
    'order' => 'DESC'
);

$query = new WP_Query($args);

if ($query->have_posts()) {
    while ($query->have_posts()) {
        $query->the_post();
        // Display post content
        ?>
        <article>
            <h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
            <div class="entry-content">
                <?php the_excerpt(); ?>
            </div>
        </article>
        <?php
    }
    wp_reset_postdata();
}

#Query by category ID

$args = array(
    'cat' => 5, // Category ID
    'posts_per_page' => 5
);

$query = new WP_Query($args);

#Query posts from multiple categories

// Posts in ANY of these categories (OR relationship)
$args = array(
    'category__in' => array(5, 10, 15),
    'posts_per_page' => 10
);

// Posts in ALL of these categories (AND relationship)
$args = array(
    'category__and' => array(5, 10),
    'posts_per_page' => 10
);

// Exclude specific categories
$args = array(
    'category__not_in' => array(3, 7),
    'posts_per_page' => 10
);

#Include child categories in WP_Query

// Get posts from category and all its children
$parent_category_id = 5;

$args = array(
    'cat' => $parent_category_id,
    'posts_per_page' => 20
);

// WP_Query automatically includes child categories when using 'cat'

#Paginate a category query on a static front page

A custom loop paginates with paged, which you read from get_query_var( 'paged' ). The exception is a page template set as the static front page: there WordPress stores the page number in the page query variable, so the WP_Query reference tells you to read get_query_var( 'page' ) instead. Use the wrong one and every click on “next” shows page one again.

$paged = ( is_front_page() && is_page() ) ? get_query_var( 'page' ) : get_query_var( 'paged' );
$paged = max( 1, (int) $paged );

$query = new WP_Query( array(
    'category_name'  => 'news',
    'posts_per_page' => 10,
    'paged'          => $paged,
) );

#Which category parameters include child categories

The same category ID returns different post lists depending on the interface you query it through. This is the detail behind most “the list is missing posts” reports, and the rules are fixed in core code rather than in settings:

InterfaceArgumentPosts from child categories
WP_Querycatincluded
WP_Querycategory_nameincluded
WP_Querycategory__in, category__and, category__not_innot included
WP_Querytax_queryincluded, unless include_children is false
get_posts()categoryincluded (it is an alias of cat)
REST APIcategories=5not included
REST APIcategories[terms]=5&categories[include_children]=trueincluded
Query Loop blockcategory filternot included
WP-CLI--cat, --category_nameincluded (passed straight to WP_Query)

The WP_Query rows come from WP_Query::parse_tax_query(): cat is turned into a tax query with include_children set to true, while category__in, category__and and category__not_in are turned into tax queries with include_children set to false. The WP_Query reference states the same in its examples, noting that category__in “does not show posts from any children of these categories”. The get_posts() row follows from its documentation, which describes category as an alias of cat. The REST rows come from the posts controller, where include_children defaults to false and is only offered for hierarchical taxonomies. The Query Loop row comes from build_query_vars_from_query_block() in core’s blocks.php, which hard-codes include_children => false for every taxonomy filter.

The practical consequence: a PHP template using cat => 5 and a headless front end calling /wp/v2/posts?categories=5 can show different posts for the same parent category. Decide which behaviour you want, then pick the argument that gives it, instead of assuming the two agree.

#WordPress get_posts by category

For simpler use cases, get_posts() provides a more straightforward API.

#get_posts by category ID

$posts = get_posts(array(
    'category' => 5,
    'posts_per_page' => 10,
    'orderby' => 'date',
    'order' => 'DESC'
));

foreach ($posts as $post) {
    setup_postdata($post);
    ?>
    <article>
        <h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
    </article>
    <?php
}
wp_reset_postdata();

#get_posts by category name

$posts = get_posts(array(
    'category_name' => 'technology',
    'numberposts' => 5
));

#What get_posts changes compared with WP_Query

get_posts() is a thin wrapper that builds a WP_Query with its own defaults. Reading the function source shows four differences that matter for category lists:

  • numberposts defaults to 5, so a call without a limit silently returns five posts.
  • post_status defaults to publish and suppress_filters defaults to true, so posts_where and similar SQL filters from plugins do not run. The pre_get_posts action still fires.
  • ignore_sticky_posts and no_found_rows are always forced to true. You cannot build numbered pagination on top of get_posts(), because the total count is never calculated.
  • include sets posts_per_page to the number of IDs you pass, overriding your own limit.

One more trap applies when you call get_posts() inside a function or a shortcode callback. setup_postdata() prepares the template tags, but the_title() and the_permalink() still read the global $post. Declare global $post; before the foreach ( $posts as $post ) loop, as the examples in the function reference do, and finish with wp_reset_postdata(). Without the global declaration, every item in the list prints the title of the page the shortcode sits on.

#Exclude a category from a WordPress query

Excluding works in two ways, and they treat child categories differently. A negative ID in cat excludes the category and its children, because core builds a NOT IN tax query with include_children set to true. category__not_in excludes only the listed terms, so posts filed in a child category still appear.

// Excludes category 12 and every child of 12
$args = array( 'cat' => '-12' );

// Excludes only posts filed directly in 12 or 34
$args = array( 'category__not_in' => array( 12, 34 ) );

To keep a category off the blog home page, change the main query instead of running a second loop. The pre_get_posts reference recommends exactly these guards:

add_action( 'pre_get_posts', function ( $query ) {
    if ( ! is_admin() && $query->is_main_query() && $query->is_home() ) {
        $query->set( 'cat', '-12' );
    }
} );

#Shortcode to list posts from a category

Creating a shortcode allows content editors to insert category post lists anywhere.

function category_posts_shortcode($atts) {
    $atts = shortcode_atts(array(
        'category' => '',
        'posts' => 5,
        'orderby' => 'date',
        'order' => 'DESC'
    ), $atts);
    
    $args = array(
        'category_name' => $atts['category'],
        'posts_per_page' => intval($atts['posts']),
        'orderby' => $atts['orderby'],
        'order' => $atts['order']
    );
    
    $query = new WP_Query($args);
    
    ob_start();
    
    if ($query->have_posts()) {
        echo '<div class="category-posts-list">';
        while ($query->have_posts()) {
            $query->the_post();
            ?>
            <article class="category-post">
                <h3><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h3>
                <p><?php the_excerpt(); ?></p>
            </article>
            <?php
        }
        echo '</div>';
    } else {
        echo '<p>No posts found in this category.</p>';
    }
    
    wp_reset_postdata();
    
    return ob_get_clean();
}
add_shortcode('category_posts', 'category_posts_shortcode');

Usage: [category_posts category="news" posts="5"]

#Change category archive queries with pre_get_posts

When you want to change which posts appear on category archive pages, use the pre_get_posts action.

function modify_category_queries($query) {
    // Only modify category archives on the main query
    if ($query->is_category() && $query->is_main_query() && !is_admin()) {
        // Show 20 posts per page instead of default
        $query->set('posts_per_page', 20);
        
        // Exclude posts from specific category on certain category pages
        $current_cat = get_queried_object();
        if ($current_cat->slug === 'featured') {
            $query->set('category__not_in', array(10)); // Exclude category ID 10
        }
    }
}
add_action('pre_get_posts', 'modify_category_queries');

Use the method versions of the conditional tags inside the callback ($query->is_category(), $query->is_main_query()). The plain functions test the global $wp_query, not the query object passed to the hook, which the hook reference calls out explicitly.

#Why query_posts is the wrong way to filter by category

Older tutorials put query_posts( 'cat=5' ) at the top of a template. The docblock of query_posts() in core says the function completely overrides the main query, is not intended for use by plugins or themes, and should be replaced by the pre_get_posts action in most cases. The function body is one line that assigns a new WP_Query to the global $wp_query. A template runs after the main query, so the call repeats the database work and replaces the object that pagination and conditional tags read. Every category task that used query_posts() maps to either pre_get_posts (change the archive) or a separate WP_Query (add a second list).

#Filter posts by category with tax_query

The cat and category_name shortcuts are convenient, but tax_query is the argument you reach for once requirements get specific: combining categories with tags, excluding child terms, or querying a custom taxonomy. It also expresses AND/OR logic explicitly, which the shortcut arguments cannot.

$args = array(
    'posts_per_page' => 10,
    'tax_query' => array(
        'relation' => 'AND',
        array(
            'taxonomy' => 'category',
            'field'    => 'slug',
            'terms'    => array('news'),
        ),
        array(
            'taxonomy' => 'post_tag',
            'field'    => 'slug',
            'terms'    => array('featured'),
        ),
    ),
);

$query = new WP_Query($args);

The include_children key is the practical detail most people miss. By default a category tax_query pulls in every descendant term, which is usually what an archive wants but rarely what a curated homepage block wants:

'tax_query' => array(
    array(
        'taxonomy'         => 'category',
        'field'            => 'term_id',
        'terms'            => array(5),
        'include_children' => false, // only posts filed directly under term 5
    ),
),

Because tax_query works against any taxonomy, the same pattern retrieves posts from a custom product_cat, portfolio_type, or any taxonomy your theme registers, without a separate function per taxonomy.

Category filters also combine with custom fields. An events list that shows only upcoming entries from two categories pairs category__in with a meta_query and sorts by the date field:

$args = array(
    'posts_per_page' => 10,
    'category__in'   => array( get_cat_ID( 'Conferences' ), get_cat_ID( 'Workshops' ) ),
    'meta_key'       => 'event_date',
    'orderby'        => 'meta_value',
    'order'          => 'ASC',
    'meta_query'     => array(
        array(
            'key'     => 'event_date',
            'value'   => current_time( 'Y-m-d' ),
            'compare' => '>=',
            'type'    => 'DATE',
        ),
    ),
);

get_cat_ID() takes the category name, not the slug, and returns 0 when nothing matches. A 0 inside category__in matches no posts, so check the IDs before running the query on a production page.

#Speed up category queries with caching

#1. Use transients for expensive queries

function get_cached_category_posts($category_id, $count = 5) {
    $cache_key = 'cat_posts_' . $category_id . '_' . $count;
    $posts = get_transient($cache_key);
    
    if (false === $posts) {
        $args = array(
            'cat' => $category_id,
            'posts_per_page' => $count
        );
        
        $query = new WP_Query($args);
        $posts = $query->posts;
        
        // Cache for 1 hour
        set_transient($cache_key, $posts, HOUR_IN_SECONDS);
    }
    
    return $posts;
}

#2. Optimize database queries

// Only retrieve fields you need
$args = array(
    'category_name' => 'news',
    'posts_per_page' => 10,
    'fields' => 'ids' // Only get post IDs for better performance
);

$query = new WP_Query($args);

The fields argument accepts exactly three values, according to the WP_Query reference: all (the default), ids, and id=>parent. Anything else, including an array of column names, silently returns full post objects. Two more switches cut work for lists that only print a title and a link:

$args = array(
    'category_name'          => 'news',
    'posts_per_page'         => 5,
    'no_found_rows'          => true,  // skip SQL_CALC_FOUND_ROWS, no pagination needed
    'update_post_meta_cache' => false, // custom fields are not printed
    'update_post_term_cache' => false, // terms are not printed
);

no_found_rows stops WordPress from counting the total number of matching rows, which it only needs for pagination. The two cache flags default to true in core; the reference says you should not normally change them, but switching them off saves the extra meta and term queries when the list never reads that data. Leave them on when the loop prints categories, tags or custom fields, because priming the caches in one query is cheaper than one query per post.

#3. Use object caching

If your site uses an object cache (Redis, Memcached), WP_Query results are automatically cached, improving performance for repeated queries.

#Category templates, AJAX, Query Loop and REST API

#Custom templates for category archives

Create a template file category-news.php for specific category styling:

<?php
/* Category archive template for the "news" slug, picked up by the template hierarchy */
get_header(); ?>

<div class="category-archive">
    <h1><?php single_cat_title(); ?></h1>
    
    <?php if (have_posts()) : ?>
        <div class="posts-grid">
            <?php while (have_posts()) : the_post(); ?>
                <?php get_template_part('content', 'category'); ?>
            <?php endwhile; ?>
        </div>
        
        <?php the_posts_pagination(); ?>
    <?php else : ?>
        <p>No posts found in this category.</p>
    <?php endif; ?>
</div>

<?php get_footer(); ?>

#Load category posts with AJAX

For better user experience, implement AJAX loading. Two details trip people up: ajaxurl is only defined in wp-admin, so on the front end you must pass it in yourself, and every request needs a nonce to survive a security review.

Enqueue the script and hand it the admin-ajax URL plus a nonce:

function enqueue_category_loader() {
    wp_enqueue_script(
        'category-loader',
        get_theme_file_uri('/js/category-loader.js'),
        array('jquery'),
        '1.0',
        true
    );
    wp_localize_script('category-loader', 'catLoader', array(
        'ajaxurl' => admin_url('admin-ajax.php'),
        'nonce'   => wp_create_nonce('load_category_posts'),
    ));
}
add_action('wp_enqueue_scripts', 'enqueue_category_loader');

The front-end request then reads from the localized object:

jQuery(document).ready(function($) {
    $('.load-more').on('click', function() {
        var button = $(this);

        $.ajax({
            url: catLoader.ajaxurl,
            type: 'POST',
            data: {
                action: 'load_category_posts',
                nonce: catLoader.nonce,
                category: button.data('category'),
                page: button.data('page')
            },
            success: function(response) {
                $('.posts-container').append(response);
                button.data('page', button.data('page') + 1);
            }
        });
    });
});

Finally, register the handler for both logged-in and anonymous visitors (wp_ajax_ and wp_ajax_nopriv_), verify the nonce, and return the rendered markup:

function load_category_posts_handler() {
    check_ajax_referer('load_category_posts', 'nonce');

    $category = sanitize_text_field($_POST['category'] ?? '');
    $page     = max(1, intval($_POST['page'] ?? 1));

    $query = new WP_Query(array(
        'category_name'  => $category,
        'posts_per_page' => 5,
        'paged'          => $page,
    ));

    if ($query->have_posts()) {
        while ($query->have_posts()) {
            $query->the_post();
            printf(
                '<article><h3><a href="%s">%s</a></h3></article>',
                esc_url(get_permalink()),
                esc_html(get_the_title())
            );
        }
        wp_reset_postdata();
    }

    wp_die(); // required so admin-ajax stops cleanly
}
add_action('wp_ajax_load_category_posts', 'load_category_posts_handler');
add_action('wp_ajax_nopriv_load_category_posts', 'load_category_posts_handler');

#Displaying category posts in a block theme

On block themes (the default theme type since Twenty Twenty-Two shipped with WordPress 5.9) you often do not need PHP at all. The Query Loop block filtered by category renders the same list from the editor: insert a Query Loop, open the block settings, and under Filters add the category you want. Save it as a synced pattern and editors can drop the curated list into any page without touching a template.

The block’s category filter matches only the terms you select. Core converts it into a tax query with include_children set to false, so selecting a parent category does not pull in posts filed only under its children; add the child categories to the filter as well.

Reach for the code approaches above when you need logic the block cannot express: transient caching, conditional exclusions per archive, or output consumed by something other than the theme (an email digest, a REST response).

#Fetching category posts through the REST API

For headless front ends, a React island, or an external integration, WordPress exposes category posts over the REST API with no custom endpoint required:

GET /wp-json/wp/v2/posts?categories=5&per_page=10&_fields=id,title,link,excerpt

Use categories_exclude to filter out a term, _embed to pull featured images and term names in one round trip, and _fields to trim the payload to what the client actually renders. To resolve a slug to the ID the endpoint expects, query /wp-json/wp/v2/categories?slug=news first.

Three details from the REST handbook decide whether a headless list is complete:

  • Child categories need the object form. categories=5 matches only term 5. To include its children, send categories[terms]=5&categories[include_children]=true. The same object accepts categories[operator]=AND when a post must carry all listed terms.
  • Pages are capped at 100 items. The pagination guide limits per_page to a value between 1 and 100. Read the X-WP-Total and X-WP-TotalPages response headers and request page=2, page=3 until you have everything.
  • _fields supports nested keys. Since WordPress 5.3 the _fields parameter accepts paths such as meta.event_date, so a list can request one registered meta key without the whole meta object. WordPress then skips computing the fields you did not ask for, which also shortens the response time.

#Debugging a category query that returns the wrong posts

When a query returns too many, too few, or unexpected posts, inspect the SQL it actually ran rather than guessing at the arguments. Install Query Monitor and it shows every query on the page, including the one your loop generated, with the slow ones flagged. For a quick check without a plugin, dump the parsed query:

$query = new WP_Query($args);
// The exact SQL WordPress built from your $args
error_log($query->request);

The two culprits behind most surprises are child-term inclusion (see include_children above) and a stray pre_get_posts filter from a plugin or the theme altering the main query. If the SQL looks right but the result is empty, confirm the term actually has published posts assigned in the current language or context.

#List posts from a category with WP-CLI

For audits, migrations and one-off exports, wp post list runs the same WP_Query from the command line. Every --<field>=<value> option is passed to WP_Query as an argument, so the category rules from the table above apply unchanged:

# Find the term ID and post count for each category
wp term list category --fields=term_id,name,slug,count

# Published posts in "news" and its child categories, as CSV
wp post list --category_name=news --post_status=publish --fields=ID,post_title,post_date --format=csv

# IDs only, posts filed directly in category 12 (no children)
wp post list --category__in=12 --post_status=publish --format=ids

# How many posts would a template show for category 12 and its children
wp post list --cat=12 --post_status=publish --format=count

Two defaults differ from a template loop. The command source sets posts_per_page to -1 and post_status to any, so without --post_status=publish the output includes drafts, pending and private posts. The default columns are ID, post_title, post_name, post_date and post_status, and --format accepts table, csv, ids, json, count and yaml. Comparing --format=count for --cat and --category__in on the same ID is a quick way to see how many posts sit only in child categories.

#Common WP_Query category mistakes

  1. Not resetting post data: Always call wp_reset_postdata() after custom loops
  2. Querying on every page load: Use caching for expensive queries
  3. Not checking if posts exist: Always verify have_posts() before looping
  4. Modifying the main query incorrectly: Use pre_get_posts instead of creating new queries on archive pages
  5. Ignoring pagination: Remember to handle pagination for large category archives

#Conclusion

WordPress provides multiple ways to extract posts from categories, each suited to different scenarios:

  • WP_Query: Best for complex, custom displays
  • get_posts(): Ideal for simple post lists
  • Shortcodes: Perfect for content editor flexibility
  • pre_get_posts: Essential for modifying archive pages

Understanding these methods and when to use each one will make you a more effective WordPress developer. Remember to always consider performance, especially on sites with large amounts of content.

For production sites, implement caching strategies and test your queries with tools like Query Monitor to ensure optimal performance.

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.

Do I need to be an experienced developer to use this guide?#
This guide is designed for intermediate WordPress developers. Basic PHP and WordPress knowledge is assumed, but all code is explained step-by-step.
Will these techniques work with any WordPress theme?#
Most techniques are theme-agnostic and work with standard WordPress themes. However, some heavily customized themes may require additional adaptation.
Is it safe to implement these changes on a live site?#
The custom loops in methods 1 to 3 stay contained, they build their own WP_Query and close with wp_reset_postdata(). The one to stage first is method 4, because pre_get_posts reaches every archive on the site, so keep its is_main_query() and !is_admin() guards in place and confirm the result with Query Monitor. Remember that the transient example caches for HOUR_IN_SECONDS, so a change can stay invisible for an hour until you delete the cached key.
Do these methods work with page builders like Elementor?#
The shortcode from method 3 is the one that travels, builders render it from a text or shortcode widget and it takes the category and posts attributes with no builder-specific glue. The Query Loop block belongs to the block editor, so it is not available on a builder canvas. When the builder assembles the list in the browser, read it from /wp-json/wp/v2/posts?categories=5 rather than expecting a PHP loop to run.
Do I still need code on a block theme?#
Not for a basic list. The Query Loop block filtered by category renders category posts from the editor. Use the code approaches when you need caching, conditional exclusions, or output consumed outside the theme, such as a REST response.

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

Let’s discuss

Related Articles