Three latest posts on the front page, related articles below an article, recommendations in the sidebar—when you want posts separately from the main loop, you use WP_Query. The shape is just this.
<?php
$query = new WP_Query([
'post_type' => 'post',
'posts_per_page' => 3,
]);
?>
<?php if ($query->have_posts()) : ?>
<ul>
<?php while ($query->have_posts()) : $query->the_post(); ?>
<li><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></li>
<?php endwhile; ?>
</ul>
<?php endif; ?>
<?php wp_reset_postdata(); ?>Two things matter: prefixing with $query->, and the wp_reset_postdata() at the end.
Why the $query-> prefix?
WordPress prepares a list of posts for the URL you’re on in advance (the main query). Call have_posts() or the_post() with no prefix and you operate on that main query.
In a loop over a $query you built yourself, name the target explicitly with $query->have_posts() / $query->the_post()—that’s the basic rule of sub-loops. Conversely, display functions inside the loop like the_title() and the_permalink() work as-is without the prefix (because the_post() has already switched “the current post” for you).
What happens if you forget wp_reset_postdata()?
$query->the_post() replaces “the current post” with the one from your sub-loop. That state persists even after you leave the loop.
So forgetting it causes breakage like “after the related-posts list on an article page, the author information below it shows the author of the last related post.” It’s also a cause of get_field() returning another post’s value.
Write a sub-loop, and put wp_reset_postdata() right below where it closes. Write them as a pair until your hands remember.
Cheat sheet of commonly used arguments
| Argument | Meaning | Example |
|---|---|---|
post_type | Post type | 'post' / 'shohin' / ['post','shohin'] |
posts_per_page | How many to fetch (-1 for all) | 5 |
orderby | What to sort by | 'date' / 'rand' / 'menu_order' / 'meta_value_num' |
order | Ascending or descending | 'DESC' (newest first) / 'ASC' |
offset | How many to skip from the start | 3 |
post__not_in | Post IDs to exclude | [get_the_ID()] |
category_name | A category slug | 'news' |
tag | A tag slug | 'sale' |
tax_query | Filter by custom taxonomy | See the link below |
meta_query | Filter by custom field | See the link below |
post_status | Publication status | 'publish' (default) |
paged | The current page for pagination | get_query_var('paged') |
ignore_sticky_posts | Ignore sticky posts | true |
Example: list the newest custom post type entries
<?php
$news = new WP_Query([
'post_type' => 'shohin',
'posts_per_page' => 6,
'orderby' => 'date',
'order' => 'DESC',
]);
?>
<?php if ($news->have_posts()) : ?>
<div class="cards">
<?php while ($news->have_posts()) : $news->the_post(); ?>
<article class="card">
<a href="<?php the_permalink(); ?>">
<?php the_post_thumbnail('medium'); ?>
<h3><?php the_title(); ?></h3>
<time><?php echo esc_html(get_the_date('Y.m.d')); ?></time>
</a>
</article>
<?php endwhile; ?>
</div>
<?php else : ?>
<p>No products yet.</p>
<?php endif; ?>
<?php wp_reset_postdata(); ?>Writing an else means the layout doesn’t break at zero results, and you can show a message. It’s a move worth including on any site you’re delivering.
Example: related posts (same category, excluding the current one)
<?php
$cats = wp_get_post_categories(get_the_ID());
$related = new WP_Query([
'category__in' => $cats,
'post__not_in' => [get_the_ID()], // exclude the current post
'posts_per_page' => 4,
'orderby' => 'rand',
]);
?>TipsBuilding related posts without a pluginRelated posts get a dedicated article with more detailHow it differs from get_posts()
get_posts() is a function that returns an array of posts, and it’s shorter to write. It’s handy when you don’t need a loop and just want to output one list.
<?php
$posts = get_posts([
'post_type' => 'post',
'numberposts' => 5, // ← get_posts uses numberposts, not posts_per_page
]);
?>
<ul>
<?php foreach ($posts as $p) : ?>
<li>
<a href="<?php echo esc_url(get_permalink($p)); ?>"><?php echo esc_html(get_the_title($p)); ?></a>
</li>
<?php endforeach; ?>
</ul>Here’s the rule of thumb for choosing.
WP_Query—when you want the post loop’s functions, like featured images, post content andthe_content(). It can do pagination tooget_posts()—a simple list of just titles and URLs. The count argument isnumberposts
Note that if you use setup_postdata($p) or the_post() after get_posts(), you need wp_reset_postdata() just as with WP_Query.
To change the main list itself, use pre_get_posts
“I want category pages to show 20 posts.” “I want to change the archive’s sort order.”—those are adjustments to the main query, not a sub-loop. Hook them in functions.php.
function my_pre_get_posts($query) {
if (is_admin() || !$query->is_main_query()) {
return; // don't affect the admin screen or sub-loops
}
if (is_category()) {
$query->set('posts_per_page', 20);
}
}
add_action('pre_get_posts', 'my_pre_get_posts');Always include the is_admin() and is_main_query() guards. Without them you drag in admin screen lists and other sub-loops, producing bugs that are hard to trace.
Small tricks for sites with a lot of content
'no_found_rows' => true—on a list that doesn’t need pagination, skip the total count calculation to lighten it- Be careful with
'posts_per_page' => -1—it gets heavy fast as posts pile up. Set an upper limit 'ignore_sticky_posts' => true—stop sticky posts from slipping in
Summary
- A separate list means
new WP_Query([...]). In the loop, name the target with$query->have_posts()/$query->the_post() - Always call
wp_reset_postdata(). Forget it and the lower part of the page—and your custom fields—break - For a simple list of titles and URLs, use
get_posts()(the count argument isnumberposts) - To change the main list, use
pre_get_posts. Don’t usequery_posts()
Remember it as “adjust the headline list with a hook, add the extra lists with WP_Query” and the confusing cases mostly disappear.