WordPress WP_Query and The Loop — Complete Guide to Custom Queries
In this tutorial, you'll learn to use WP_Query for custom database queries in WordPress — mastering every parameter type from tax_query and meta_query to date queries, pagination, and pre_get_posts modifications.
What You'll Learn
- What WP_Query is and how it retrieves posts from the MySQL database
- The basic query structure with new WP_Query, have_posts, and the_post
- All query parameters: post_type, posts_per_page, orderby, order, and more
- How tax_query filters posts by category, tag, or custom taxonomy terms
- How meta_query filters by custom field values with comparison operators
- How date_query filters by year, month, day, and date ranges
- How to customize loop output for different post types
- How to run nested loops without conflicts
- How to add pagination with paginate_links
- How pre_get_posts modifies the main query before it runs
Why It Matters
By default, WordPress shows the 10 most recent posts on your blog page and a single post on its singular page. Real-world sites need more control. A portfolio needs the latest 6 projects sorted by completion date, excluding featured ones. An events site needs upcoming events sorted by event date (a custom field), not publish date. A job board needs to filter by category, location, and salary range simultaneously. WP_Query gives you complete control over what content displays and in what order. Without it, you are stuck with defaults that never match real requirements.
Real-World Use
A conference website needs to display three separate lists on the homepage: "Upcoming Workshops" (filtered by a custom field date in the future, sorted by date ascending), "Featured Speakers" (posts with a "featured" checkbox checked), and "Latest News" (the 3 most recent posts excluding those in the "sponsors" category). Each section runs its own WP_Query with different parameters. Without WP_Query, the developer would have to write raw PHP SQL or use multiple plugins for each section.
Learning Path
flowchart LR
A[Custom Taxonomies & Fields] --> B["WP_Query & The Loop
You are here"]:::current
B --> C[Hooks]
C --> D[Shortcodes]
classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px
What Is WP_Query?
WP_Query is a PHP class that communicates with the WordPress database and retrieves posts matching your criteria. Think of it as a smart search assistant. You tell it: "Find me 6 published posts in the 'portfolio' post type, from the 'design' category, sorted by title alphabetically." WP_Query builds the SQL query, runs it against MySQL, and returns the matching posts as an array of WP_Post objects.
Every page in WordPress uses WP_Query internally. The main query runs before your template loads. When you use new WP_Query($args), you create a secondary query that runs alongside (or instead of) the main one.
Basic Query Structure
$args = array(
'post_type' => 'post',
'posts_per_page' => 5,
'orderby' => 'date',
'order' => 'DESC',
);
$query = new WP_Query( $args );
if ( $query->have_posts() ) :
while ( $query->have_posts() ) : $query->the_post();
?>
<h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
<p class="meta">
<?php echo get_the_date(); ?> by <?php the_author(); ?>
</p>
<div class="excerpt">
<?php the_excerpt(); ?>
</div>
<a href="<?php the_permalink(); ?>" class="read-more">Read More</a>
<?php
endwhile;
wp_reset_postdata();
else :
?>
<p>No posts found.</p>
<?php
endif;
The three essential methods:
have_posts()— returns true if there are posts remaining in the loopthe_post()— advances the internal pointer and sets up $post global, providing access to template tags likethe_title(),the_content(), etc.wp_reset_postdata()— restores the global $post variable to the current post in the main query
You might wonder why we call wp_reset_postdata(). Without it, template tags like the_title() continue to reference the last post from your custom query, breaking everything after your loop. Think of it as returning a borrowed book to the correct shelf.
Query Parameters
Basic Parameters
$args = array(
'p' => 42, // Specific post ID
'page_id' => 15, // Specific page ID
'post_type' => 'product', // Post type slug
'posts_per_page' => 10, // Posts per page (-1 for all)
'post_status' => 'publish', // publish, draft, pending, private, trash
'post__in' => array( 1, 3, 5 ), // Include specific IDs
'post__not_in' => array( 2, 4 ), // Exclude specific IDs
);
Order Parameters
$args = array(
'orderby' => 'date',
'order' => 'DESC',
);
Available orderby values:
| Value | Description |
|---|---|
date |
Post publish date (default) |
title |
Post title alphabetically |
name |
Post slug alphabetically |
author |
Author ID |
rand |
Random order |
comment_count |
Number of comments |
menu_order |
Custom order (for pages) |
meta_value |
Custom field value (requires meta_key) |
meta_value_num |
Custom field value as number (requires meta_key) |
post__in |
Preserve order of post__in array |
For custom field ordering:
$args = array(
'orderby' => 'meta_value_num',
'meta_key' => 'event_date',
'order' => 'ASC',
'meta_type' => 'DATETIME',
);
tax_query
tax_query filters posts by taxonomy terms. Each inner array is one condition:
$args = array(
'post_type' => 'post',
'tax_query' => array(
array(
'taxonomy' => 'category',
'field' => 'slug',
'terms' => 'news',
),
),
);
Multiple taxonomies with different operators:
$args = array(
'post_type' => 'product',
'tax_query' => array(
array(
'taxonomy' => 'category',
'field' => 'slug',
'terms' => array( 'clothing', 'accessories' ),
'operator' => 'IN',
),
array(
'taxonomy' => 'brand',
'field' => 'slug',
'terms' => 'nike',
),
),
);
Operators: IN, NOT IN, AND, EXISTS, NOT EXISTS.
The relation parameter controls how multiple queries combine:
$args = array(
'post_type' => 'post',
'tax_query' => array(
'relation' => 'OR',
array(
'taxonomy' => 'category',
'field' => 'slug',
'terms' => 'featured',
),
array(
'taxonomy' => 'post_tag',
'field' => 'slug',
'terms' => 'urgent',
),
),
);
This returns posts that are either in "featured" category OR tagged "urgent". Without the relation parameter, multiple queries default to AND.
meta_query
meta_query filters by custom field values:
$args = array(
'post_type' => 'event',
'meta_query' => array(
array(
'key' => 'event_date',
'value' => date( 'Y-m-d' ),
'compare' => '>=',
'type' => 'DATE',
),
array(
'key' => 'featured',
'value' => '1',
'compare' => '=',
),
),
);
Comparison operators:
| Operator | Description |
|---|---|
= |
Equal to |
!= |
Not equal to |
> |
Greater than |
>= |
Greater than or equal |
< |
Less than |
<= |
Less than or equal |
LIKE |
Contains value |
NOT LIKE |
Does not contain |
IN |
Value is in array |
NOT IN |
Value is not in array |
BETWEEN |
Value is between two values (array) |
NOT BETWEEN |
Value is not between |
EXISTS |
Meta key exists |
NOT EXISTS |
Meta key does not exist |
BETWEEN example:
$args = array(
'meta_query' => array(
array(
'key' => 'price',
'value' => array( 10, 50 ),
'compare' => 'BETWEEN',
'type' => 'NUMERIC',
),
),
);
date_query
date_query filters by date-based criteria:
$args = array(
'date_query' => array(
array(
'year' => 2026,
'month' => 6,
),
),
);
Complex date range:
$args = array(
'date_query' => array(
array(
'after' => array(
'year' => 2026,
'month' => 1,
'day' => 1,
),
'before' => array(
'year' => 2026,
'month' => 6,
'day' => 30,
),
'inclusive' => true,
),
),
);
The inclusive parameter set to true includes posts from the exact after/before dates. Set to false (default) excludes them.
String-based dates also work:
$args = array(
'date_query' => array(
array(
'after' => 'January 1st, 2026',
'before' => array(
'year' => 2026,
'month' => 12,
'day' => 31,
),
),
),
);
Nested Loops
Sometimes you need a custom query inside the main loop. For example, showing related posts:
<?php
while ( have_posts() ) : the_post(); ?>
<h1><?php the_title(); ?></h1>
<?php the_content(); ?>
<h2>Related Posts</h2>
<?php
$categories = wp_get_post_categories( get_the_ID() );
$related_args = array(
'category__in' => $categories,
'post__not_in' => array( get_the_ID() ),
'posts_per_page' => 3,
);
$related_query = new WP_Query( $related_args );
if ( $related_query->have_posts() ) :
while ( $related_query->have_posts() ) : $related_query->the_post(); ?>
<h3><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h3>
<?php endwhile;
wp_reset_postdata();
endif;
?>
<?php endwhile; ?>
You might notice we call wp_reset_postdata() after the inner loop but not after the outer one. The outer loop resets automatically when WordPress finishes the main query. You call wp_reset_postdata() after every custom WP_Query to restore the main query's current post.
Pagination with WP_Query
Custom queries need manual pagination. Pass the current page to paged:
$paged = get_query_var( 'paged' ) ? get_query_var( 'paged' ) : 1;
$args = array(
'post_type' => 'portfolio',
'posts_per_page' => 6,
'paged' => $paged,
);
$query = new WP_Query( $args );
if ( $query->have_posts() ) :
while ( $query->have_posts() ) : $query->the_post(); ?>
<article>
<?php the_post_thumbnail( 'medium' ); ?>
<h2><?php the_title(); ?></h2>
</article>
<?php endwhile;
$big = 999999999;
echo paginate_links( array(
'base' => str_replace( $big, '%#%', esc_url( get_pagenum_link( $big ) ) ),
'format' => '?paged=%#%',
'current' => max( 1, $paged ),
'total' => $query->max_num_pages,
) );
wp_reset_postdata();
endif;
paginate_links() generates the previous/next and numeric page links. The $big trick with str_replace handles pretty permalinks correctly.
pre_get_posts Filter
pre_get_posts modifies the main query before it runs. Use it to change what appears on archive pages without creating custom templates:
function dodatech_modify_archive_query( $query ) {
if ( ! is_admin() && $query->is_main_query() ) {
if ( is_post_type_archive( 'event' ) ) {
$query->set( 'posts_per_page', 12 );
$query->set( 'orderby', 'meta_value_num' );
$query->set( 'meta_key', 'event_date' );
$query->set( 'order', 'ASC' );
$query->set( 'meta_query', array(
array(
'key' => 'event_date',
'value' => date( 'Y-m-d' ),
'compare' => '>=',
'type' => 'DATE',
),
) );
}
if ( is_category( 'news' ) ) {
$query->set( 'posts_per_page', 20 );
}
if ( is_search() ) {
$query->set( 'post_type', array( 'post', 'page', 'product' ) );
}
}
}
add_action( 'pre_get_posts', 'dodatech_modify_archive_query' );
Always check ! is_admin() and $query->is_main_query() in pre_get_posts. Without these checks, you modify admin queries and AJAX requests, causing unpredictable behavior.
WP_Query vs query_posts vs get_posts
| Method | Use Case | Resets Globals |
|---|---|---|
WP_Query |
Secondary loops, custom queries on any page | Must call wp_reset_postdata() |
query_posts() |
Never use | Modifies main query, breaks things |
get_posts() |
Simple lists without Loop overhead | Uses WP_Query internally, no need to reset |
The rule: use WP_Query for custom queries, get_posts for simple lists, and never query_posts().
Displaying Query Results in Different Formats
// List format
$query = new WP_Query( array( 'posts_per_page' => 5 ) );
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 wp_reset_postdata();
endif;
// Grid format
$query = new WP_Query( array( 'post_type' => 'portfolio', 'posts_per_page' => 9 ) );
if ( $query->have_posts() ) : ?>
<div class="grid grid-cols-3">
<?php while ( $query->have_posts() ) : $query->the_post(); ?>
<div class="grid-item">
<?php the_post_thumbnail( 'medium' ); ?>
<h3><?php the_title(); ?></h3>
</div>
<?php endwhile; ?>
</div>
<?php wp_reset_postdata();
endif;
// Table format
$query = new WP_Query( array( 'post_type' => 'product', 'posts_per_page' => 20 ) );
if ( $query->have_posts() ) : ?>
<table>
<thead>
<tr>
<th>Product</th>
<th>Price</th>
<th>SKU</th>
</tr>
</thead>
<tbody>
<?php while ( $query->have_posts() ) : $query->the_post();
$price = get_post_meta( get_the_ID(), 'price', true );
$sku = get_post_meta( get_the_ID(), 'sku', true );
?>
<tr>
<td><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></td>
<td>$<?php echo esc_html( $price ); ?></td>
<td><?php echo esc_html( $sku ); ?></td>
</tr>
<?php endwhile; ?>
</tbody>
</table>
<?php wp_reset_postdata();
endif;
Common Mistakes
Forgetting wp_reset_postdata(). After a custom WP_Query loop, template tags point to the last post in your custom query. The footer or subsequent loops break. Always call
wp_reset_postdata()after each custom query.Using query_posts().
query_posts()overrides the main query object, breaks pagination, and interferes with plugins that hook intothe_postsfilter. Replace everyquery_posts()withpre_get_postsor a newWP_Queryinstance.Not checking is_main_query() in pre_get_posts. Without this check, your
pre_get_postscallback runs on every query — admin screens, widgets, nav menus, REST API requests. Always verify$query->is_main_query()before modifying.Setting posts_per_page to -1 without limits. Fetching all posts with
posts_per_page => -1on a site with 50,000 posts loads them all into memory. Use reasonable limits or pagination.Using meta_query with LIKE on numeric fields.
LIKEdoes string comparison. Searchingmeta_value LIKE '10'also matches 100, 101, 210, etc. Usecompare => '='withtype => 'NUMERIC'for exact numeric matches.
Practice Questions
- Why does tax_query with multiple inner arrays default to AND relation, and how do you change it to OR?
- What is the difference between
wp_reset_postdata()andwp_reset_query(), and when should each be used? - A custom WP_Query returns 0 posts even though matching posts exist in the database. What are three possible causes?
Challenge: Build a homepage with three WP_Query sections: "Featured Products" (products with 'featured' meta key = '1', limit 4), "Latest Blog Posts" (most recent 3 posts, excluding the 'featured' category), and "Upcoming Events" (events with event_date >= today, sorted by event_date ascending, limit 5). Each section must have its own heading, loop, and proper post data reset. Add pagination to the events section.
FAQ
Mini Project
Create a "Staff Directory" page that uses multiple WP_Query loops:
- Create a
staffCPT with custom fields: department (text), position (text), start_date (date), bio (WYSIWYG), and photo (image) - Register a
departmenttaxonomy with terms: Engineering, Design, Marketing, Sales, Support - Build a staff directory template with:
- Department filter tabs (clicking shows only that department's staff)
- Staff grid sorted by start_date descending
- Each card shows photo, name, position, and department
- Search box filtering by name and position
- Use
pre_get_poststo set the default order on the staff archive page - Add pagination showing 12 staff per page
What's Next
Now that you can query any content with precision, learn to modify WordPress behavior at any point with hooks:
Continue to Lesson 41: Hooks — Actions and filters complete developer guide.
Related lessons:
- Custom Post Types — Create the content WP_Query retrieves
- Custom Taxonomies and Custom Fields — The fields and terms WP_Query filters by
- WordPress REST API — Use WP_Query to build custom API endpoints
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro