WordPress provides three ways to query posts: get_posts(), new WP_Query(), and query_posts(). They all accept the same query arguments and ultimately call the same database query internally, but they have very different behaviours, intended use cases, and side effects. Developers new to WordPress commonly use query_posts() because the name sounds like exactly what they want, but it is almost always the wrong choice outside of a very narrow use case. get_posts() is the right function for retrieving a list of posts for display in widgets, shortcodes, or secondary loops — it does not set up global pagination state, is safe to use anywhere, and defaults to sensible values. new WP_Query() is the right choice when you need the full Loop API (have_posts(), the_post()), pagination, or access to the query object for inspection. query_posts() should essentially never be used — it modifies the global main query object and breaks pagination. This article clarifies the differences with code examples.
Problem: You are not sure whether to use get_posts(), new WP_Query(), or query_posts() for a secondary loop, and you have seen all three used in different plugins and themes.
Solution: Use get_posts() for simple lists without a Loop, use new WP_Query() for a full Loop with pagination, and avoid query_posts() entirely — it is a wrapper that replaces the main query and is never the right tool for secondary loops.
<?php
// ── get_posts() ────────────────────────────────────────────────────────
// Use for: widget lists, shortcode output, sidebar content, meta boxes.
// Returns: array of WP_Post objects.
// Does NOT: set up global post data, support pagination, reset after use.
$recent = get_posts( [
'post_type' => 'post',
'posts_per_page' => 5,
'orderby' => 'date',
'order' => 'DESC',
'no_found_rows' => true, // skip COUNT(*) — faster when pagination is not needed
] );
foreach ( $recent as $post ) {
setup_postdata( $post ); // needed to use template tags like the_title()
the_title( '<h3>', '</h3>' );
}
wp_reset_postdata(); // always reset after setup_postdata()
// ── new WP_Query() ─────────────────────────────────────────────────────
// Use for: custom archive loops, paginated secondary loops, AJAX load more.
// Returns: WP_Query object with have_posts() / the_post() Loop API.
$query = new WP_Query( [
'post_type' => 'event',
'posts_per_page' => 10,
'paged' => max( 1, get_query_var( 'paged' ) ),
] );
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
the_title( '<h3>', '</h3>' );
}
wp_reset_postdata(); // always reset after a custom WP_Query loop
}
// ── query_posts() — DO NOT USE ─────────────────────────────────────────
// Replaces the global $wp_query — breaks pagination on archive pages.
// If you think you need it, use pre_get_posts instead.
// query_posts( [ 'posts_per_page' => 5 ] ); // ← never do this
NOTE: The most common mistake is forgetting to call wp_reset_postdata() after a custom loop. Without it, template tags like the_title(), the_content(), and get_the_ID() continue to reference the last post from your secondary query instead of the current post in the outer loop. This causes wrong titles, wrong IDs, and incorrect conditional checks in subsequent code on the same page.