WP_Query
WP_Query builds the SQL, holds the results, and exposes post_count, found_posts and max_num_pages.
$query = new WP_Query( array|string $args ); // then $query->have_posts(), $query->the_post()WP_Query is the class behind every post lookup in WordPress. The main query is a WP_Query instance stored in the global $wp_query; a secondary query is one you construct yourself. Instantiating it with arguments parses those arguments into query vars, builds the SQL against wp_posts and its join tables, runs it, and caches the resulting WP_Post objects.
Constructing and looping
Passing arguments to the constructor runs the query immediately. The two-step form is the longhand of the same operation: $q = new WP_Query(); followed by $q->query( $args ) does exactly what new WP_Query( $args ) does, because the constructor simply forwards to query(). The instance then answers have_posts(), the_post(), rewind_posts() and reset_postdata().
post_count, found_posts, max_num_pages
$q->post_count is how many rows this page returned — at most posts_per_page. $q->found_posts is how many rows matched the query in total, ignoring the LIMIT; WordPress gets it with SQL_CALC_FOUND_ROWS. $q->max_num_pages is found_posts divided by posts_per_page, rounded up, and is what pagination functions read. Use post_count to decide whether to render markup and found_posts for a "123 results" line.
What trips people up
Setting 'no_found_rows' => true drops SQL_CALC_FOUND_ROWS, which is a real speed win on unpaginated queries but leaves found_posts and max_num_pages unpopulated. get_posts() forces that flag for you, which is why counts taken from it are wrong.
Example
<?php
// A "latest reviews" block with a real result count.
$reviews = new WP_Query(
array(
'post_type' => 'review',
'post_status' => 'publish',
'posts_per_page' => 6,
'orderby' => 'date',
'order' => 'DESC',
)
);
// post_count = rows on this page (max 6).
// found_posts = total matching rows, ignoring the LIMIT.
if ( $reviews->post_count > 0 ) {
printf(
'<p>%s</p>',
esc_html(
sprintf(
/* translators: 1: shown count, 2: total count */
__( 'Showing %1$d of %2$d reviews', 'your-theme' ),
$reviews->post_count,
$reviews->found_posts
)
)
);
echo '<ul>';
while ( $reviews->have_posts() ) {
$reviews->the_post();
echo '<li><a href="' . esc_url( get_permalink() ) . '">' . esc_html( get_the_title() ) . '</a></li>';
}
echo '</ul>';
// $reviews->max_num_pages would drive pagination here.
wp_reset_postdata();
}When to use it
- Build a custom post type listing for a homepage section
- Show a total result count alongside a paginated archive
- Fetch a list of post IDs without loading full post objects
- Debug why an archive is returning unexpected posts by inspecting the generated SQL
More examples
Constructor shorthand vs the two-step longhand
Both forms produce the same object and the same SQL. Reach for the two-step version when the arguments are assembled across several branches, or when you want to reuse one instance for a second query rather than allocating another.
<?php
$args = array(
'post_type' => 'book',
'posts_per_page' => 10,
);
// Shorthand: the constructor runs the query for you.
$books = new WP_Query( $args );
// Longhand: identical, because __construct() just calls query().
$books = new WP_Query();
$books->query( $args );
// The two-step form is useful when you build args conditionally,
// or when you want to re-run the same object with different args.
if ( ! empty( $_GET['author_id'] ) ) { // sanitise before real use
$args['author'] = absint( wp_unslash( $_GET['author_id'] ) );
}
$books->query( $args );Reading the object without looping
When you want a number or a list of IDs rather than rendered posts, never loop. Asking for 'ids' and turning off the cache priming keeps the query cheap, and found_posts still gives you the true total because no_found_rows was left at its false default.
<?php
// Counting only: ask for IDs and skip the post-meta cache priming.
$count_query = new WP_Query(
array(
'post_type' => 'ticket',
'post_status' => 'publish',
'fields' => 'ids', // returns int[] instead of WP_Post[]
'posts_per_page' => 1, // we only need found_posts
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
)
);
$total = $count_query->found_posts; // total matches, not the 1 row returned
$ids = $count_query->posts; // array of post IDs
// No loop ran, so there is no global $post to restore
// and no wp_reset_postdata() call is needed.Inspecting the generated SQL while debugging
The $request property is the fastest way to find out why a query returned the wrong rows, especially once meta or taxonomy clauses are involved. Log it rather than echoing it, so the output does not leak into a page.
<?php
// With WP_DEBUG on, $query->request holds the SQL that was executed.
$q = new WP_Query(
array(
'post_type' => 'event',
'posts_per_page' => 5,
'meta_key' => 'event_start',
'orderby' => 'meta_value',
'order' => 'ASC',
)
);
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( $q->request );
error_log( 'found_posts: ' . $q->found_posts );
error_log( 'max_num_pages: ' . $q->max_num_pages );
}
Discussion