Introduction

WordPress gives you three ways to fetch posts in code: WP_Query, get_posts(), and query_posts(). They look similar, and tutorials use them interchangeably, but they are not equal. One is the right default, one is a convenient shortcut, and one you should almost never use.

This article explains what each does, how they differ, and exactly when to reach for each. By the end you will never have to guess again.

You will learn:

  • What WP_Query, get_posts(), and query_posts() actually do under the hood
  • Why query_posts() breaks pagination and should be avoided
  • When get_posts() is the cleaner choice over WP_Query
  • A simple decision rule you can apply every time

This builds on the WP_Query reference. If you have not met WP_Query yet, read that first.

Quick Answer

If you only remember one thing:

  • Use WP_Query for secondary loops where you need the full loop, pagination, or query properties.
  • Use get_posts() for a simple array of posts you will iterate with foreach, with no pagination.
  • Use pre_get_posts, not query_posts(), to change the main query. Avoid query_posts() entirely.

The rest of the article explains why.

WP_Query: The Foundation

WP_Query is the class all three approaches rely on. You create an instance, loop over it, and reset afterward. It gives you the complete loop API and useful properties like max_num_pages and found_posts.

<?php
$query = new WP_Query( array(
    'post_type'      => 'post',
    'posts_per_page' => 5,
) );

if ( $query->have_posts() ) {
    while ( $query->have_posts() ) {
        $query->the_post();
        the_title( '<h2>', '</h2>' );
    }
    wp_reset_postdata();
}

Strengths: full control, pagination support, access to query properties, and it does not disturb the main query. This is the right default for any custom list of posts in a template.

get_posts(): The Convenient Shortcut

get_posts() is a wrapper around WP_Query. It runs a query and returns just an array of post objects. It does not set up the loop for you, so you iterate with foreach.

<?php
$posts = get_posts( array(
    'numberposts' => 5,
    'category'    => 4,
) );

foreach ( $posts as $post ) {
    setup_postdata( $post );
    the_title( '<h3>', '</h3>' );
}
wp_reset_postdata();

A few things to know about get_posts():

  • It returns an array, so there is no have_posts() or max_num_pages. There is no built-in pagination.
  • It uses some different argument names, notably numberposts instead of posts_per_page (though posts_per_page also works).
  • It sets suppress_filters to true by default, so query filters that plugins add to the main query do not apply. That makes results more predictable, but means some plugin behaviour will not affect it.
  • To use template tags inside the foreach, call setup_postdata( $post ) per item and reset at the end. Otherwise just read properties like $post->post_title.

get_posts() shines for short, finite lists: a related-posts strip, a dropdown of pages, a handful of featured items. It reads cleanly when you do not need the ceremony of a full loop.

query_posts(): The One to Avoid

query_posts() looks tempting because it is a single function that seems to change which posts appear. What it actually does is throw away the main query and run a new one in its place.

<?php
// Do not do this.
query_posts( 'posts_per_page=3' );

while ( have_posts() ) {
    the_post();
    the_title();
}
wp_reset_query();

Why it is a problem:

  • It replaces the main query, so pagination breaks. The main query already knew the current page; query_posts() discards that.
  • It runs an extra database query, because the main query already ran before your template loaded.
  • It can confuse conditional tags and other code that expects the original main query.
  • It needs wp_reset_query() (not wp_reset_postdata()) to restore the main query, which is an easy thing to forget.

Every job query_posts() seems to do has a better tool. To change the main query, use pre_get_posts. To add a second list, use WP_Query. There is no scenario where query_posts() is the correct choice in modern WordPress.

Side-by-Side Comparison

How the three compare on the things that matter:

  • Returns: WP_Query returns an object you loop; get_posts() returns an array; query_posts() replaces the global main query.
  • Pagination: WP_Query supports it; get_posts() does not; query_posts() breaks the main query pagination.
  • Affects the main query: WP_Query no; get_posts() no; query_posts() yes (this is the problem).
  • Reset function: WP_Query and get_posts() use wp_reset_postdata(); query_posts() needs wp_reset_query().
  • Best for: WP_Query for secondary loops with pagination; get_posts() for quick finite arrays; query_posts() for nothing.
  • Default filters: get_posts() suppresses query filters by default; WP_Query applies them.

How to Change the Main Query Instead

People reach for query_posts() because they want the archive or home page to show different posts. The correct tool is the pre_get_posts hook, which adjusts the main query before it runs, keeping pagination and the template hierarchy intact.

<?php
add_action( 'pre_get_posts', function( $query ) {
    if ( $query->is_main_query() && ! is_admin() && $query->is_home() ) {
        $query->set( 'posts_per_page', 6 );
    }
} );

This changes the existing main query rather than replacing it, so everything that depends on it keeps working. The pre_get_posts article covers this in full.

Migrating Away From query_posts()

If you inherit a theme that uses query_posts() to change an archive, the fix is to move that intent into a pre_get_posts callback. Here is a typical before and after.

<?php
// Before: in the template, fragile and breaks pagination.
query_posts( array( 'posts_per_page' => 12, 'cat' => 7 ) );

// After: in functions.php, safe and pagination-friendly.
add_action( 'pre_get_posts', function( $query ) {
    if ( is_admin() || ! $query->is_main_query() ) {
        return;
    }
    if ( $query->is_category( 7 ) ) {
        $query->set( 'posts_per_page', 12 );
    }
} );

The template then just runs the standard Loop with no query call at all. The main query already carries your changes, and pagination keeps working because nothing replaced it.

A Simple Decision Rule

  1. Do you want to change what the main archive or home page shows? Use pre_get_posts.
  2. Do you need a second list with pagination or loop properties? Use a new WP_Query.
  3. Do you need a short, fixed array of posts to iterate? Use get_posts().
  4. Were you about to use query_posts()? Stop, and use one of the three above.

Best Practices

  • Default to WP_Query for secondary loops; it is the most capable and the safest.
  • Use get_posts() for simple finite lists, and call setup_postdata() if you use template tags inside the foreach.
  • Never use query_posts(); use pre_get_posts to modify the main query.
  • Reset with wp_reset_postdata() after WP_Query and get_posts() loops that set up post data.
  • Remember get_posts() suppresses filters by default, which can change results compared with WP_Query.

Common Mistakes

Using query_posts() for a second loop

It replaces the main query rather than creating a new one, so the page below it loses pagination and context. Use WP_Query for any additional list.

Expecting pagination from get_posts()

get_posts() returns a flat array with no paging. If you need numbered pages, use WP_Query with the paged argument instead.

Using the wrong reset function

After query_posts() you need wp_reset_query(); after WP_Query and get_posts() you need wp_reset_postdata(). Mixing them up leaves the global state wrong.

Forgetting setup_postdata() with get_posts()

Template tags inside a get_posts() foreach return nothing unless you call setup_postdata( $post ) first, because the array items do not set up the global post on their own.

Troubleshooting

Pagination disappeared after I changed the homepage query

You likely used query_posts(). Replace it with a pre_get_posts callback that sets your argument on the main query, and pagination returns.

Template tags are empty inside my get_posts() loop

Add setup_postdata( $post ) at the top of the foreach and wp_reset_postdata() after it. Without setup, the_title() and friends have no global post to read.

A plugin filter does not affect my get_posts() results

get_posts() sets suppress_filters to true. If you need those filters, switch to WP_Query, or pass 'suppress_filters' => false to get_posts().

Frequently Asked Questions

Is get_posts() just WP_Query?

Essentially yes. get_posts() creates a WP_Query internally and returns its posts array, with a couple of different defaults like suppressed filters. It is a convenience wrapper for simple lists.

Why is query_posts() still in WordPress if it is bad?

It remains for backward compatibility with old themes. Its continued existence is not an endorsement. Modern code should use pre_get_posts or WP_Query instead.

Which is faster, get_posts() or WP_Query?

They run essentially the same query, so performance is comparable. Differences come from the arguments you pass, such as no_found_rows and fields, not from the choice between the two.

How do I modify the main query without query_posts()?

Hook pre_get_posts, check is_main_query() and the relevant conditional, and call $query->set() to change arguments. This adjusts the existing query instead of replacing it.

Can I paginate a get_posts() list?

Not directly. get_posts() has no pagination support. Use WP_Query with the paged argument and paginate_links() when you need numbered pages.

Conclusion

Three functions, one clear hierarchy. WP_Query is the capable default for secondary loops. get_posts() is the clean shortcut for short, fixed lists. query_posts() is a legacy function that replaces the main query and breaks pagination, so it has no place in new code.

When you want to change what an archive shows, the answer is almost always pre_get_posts, not a new query at all. Keep the decision rule in mind and you will pick the right tool every time, with pagination and performance intact.

Next, How to Modify Main Queries with pre_get_posts shows the recommended approach in depth, and the WP_Query reference covers every argument the class accepts.