Tips・WordPress templates and fetching posts

How to fetch posts outside the loop (WP_Query)

Three latest posts on the front page, popular articles in the sidebar—how to write WP_Query to pull posts separately from the main loop. Includes a list of commonly used arguments, why cleanup matters, and how it differs from get_posts.

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.

TipsA checklist for when ACF values don't show upThis is also a cause when custom field values suddenly go wrong

Cheat sheet of commonly used arguments

ArgumentMeaningExample
post_typePost type'post' / 'shohin' / ['post','shohin']
posts_per_pageHow many to fetch (-1 for all)5
orderbyWhat to sort by'date' / 'rand' / 'menu_order' / 'meta_value_num'
orderAscending or descending'DESC' (newest first) / 'ASC'
offsetHow many to skip from the start3
post__not_inPost IDs to exclude[get_the_ID()]
category_nameA category slug'news'
tagA tag slug'sale'
tax_queryFilter by custom taxonomySee the link below
meta_queryFilter by custom fieldSee the link below
post_statusPublication status'publish' (default)
pagedThe current page for paginationget_query_var('paged')
ignore_sticky_postsIgnore sticky poststrue
TipsFiltering by category and taxonomy with tax_queryHow to write tax_query to filter by category or custom taxonomyTipsFiltering and sorting by custom field with meta_queryHow to write meta_query to filter and sort by custom field values

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.

<?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 detail

How 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 and the_content(). It can do pagination too
  • get_posts()a simple list of just titles and URLs. The count argument is numberposts

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

  1. A separate list means new WP_Query([...]). In the loop, name the target with $query->have_posts() / $query->the_post()
  2. Always call wp_reset_postdata(). Forget it and the lower part of the page—and your custom fields—break
  3. For a simple list of titles and URLs, use get_posts() (the count argument is numberposts)
  4. To change the main list, use pre_get_posts. Don’t use query_posts()

Remember it as “adjust the headline list with a hook, add the extra lists with WP_Query” and the confusing cases mostly disappear.

FAQ

What do I use to fetch posts outside the loop?
Create a new query with new WP_Query([...]) and loop with $query->have_posts() and $query->the_post(). Always call wp_reset_postdata() when you're done. For simple fetches, get_posts() works too.
Why is wp_reset_postdata() necessary?
Calling the_post() replaces "the current post" with the one from your sub-loop. Forget the reset and later calls to the_title() or get_field() keep pointing at that post, breaking the display further down the page.
I've heard I shouldn't use query_posts().
query_posts() destroys the main query, and it's officially discouraged. Use WP_Query when you want a separate list, and pre_get_posts when you want to change the conditions of the main list.
I want to exclude the post I'm currently viewing from a list.
Add 'post__not_in' => [get_the_ID()] to your arguments. That stops the current post from appearing in its own related-posts list.