WP_Query

WP_Query builds the SQL, holds the results, and exposes post_count, found_posts and max_num_pages.

Syntax$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

Example · php
<?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.

Example · php
<?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.

Example · php
<?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.

Example · php
<?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

  • Be the first to comment on this lesson.