Skip to content

WordPress WP_Query and The Loop — Complete Guide to Custom Queries

DodaTech Updated 2026-06-27 13 min read

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 loop
  • the_post() — advances the internal pointer and sets up $post global, providing access to template tags like the_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

  1. 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.

  2. Using query_posts(). query_posts() overrides the main query object, breaks pagination, and interferes with plugins that hook into the_posts filter. Replace every query_posts() with pre_get_posts or a new WP_Query instance.

  3. Not checking is_main_query() in pre_get_posts. Without this check, your pre_get_posts callback runs on every query — admin screens, widgets, nav menus, REST API requests. Always verify $query->is_main_query() before modifying.

  4. Setting posts_per_page to -1 without limits. Fetching all posts with posts_per_page => -1 on a site with 50,000 posts loads them all into memory. Use reasonable limits or pagination.

  5. Using meta_query with LIKE on numeric fields. LIKE does string comparison. Searching meta_value LIKE '10' also matches 100, 101, 210, etc. Use compare => '=' with type => 'NUMERIC' for exact numeric matches.

Practice Questions

  1. Why does tax_query with multiple inner arrays default to AND relation, and how do you change it to OR?
  2. What is the difference between wp_reset_postdata() and wp_reset_query(), and when should each be used?
  3. 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

### When should I use get_posts instead of WP_Query?

Use get_posts() when you need a simple array of posts without Loop setup — for example, building a dropdown select or populating a data structure. get_posts() returns an array of WP_Post objects directly, no while loop needed. Use WP_Query when you need pagination, custom output control, or Loop-based template tags.

Does WP_Query support searching by post content?

Yes. Use the s parameter for search queries: s => 'keyword'. WP_Query searches post title and content by default. You can customize search behavior with the posts_search filter.

Why does my WP_Query pagination show 404 on page 2?

This typically happens when you run a custom WP_Query on a page that already has a main query. WordPress does not know your custom query needs pagination. Set the paged parameter correctly from get_query_var('paged') and ensure your custom query does not interfere with WordPress's page detection.

Can I combine tax_query and meta_query in one WP_Query?

Yes. tax_query, meta_query, and date_query all work together. WordPress combines them with AND logic. Use the relation parameter inside each query type if you need OR logic within that type.

Does WP_Query cache results?

Yes. WP_Query uses WordPress's object cache. Repeated identical queries hit the cache instead of the database. Install a persistent cache plugin (Redis, Memcached) to keep cached results across page loads. Without persistent Caching, the cache lives only for the current request.

Mini Project

Create a "Staff Directory" page that uses multiple WP_Query loops:

  1. Create a staff CPT with custom fields: department (text), position (text), start_date (date), bio (WYSIWYG), and photo (image)
  2. Register a department taxonomy with terms: Engineering, Design, Marketing, Sales, Support
  3. 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
  4. Use pre_get_posts to set the default order on the staff archive page
  5. 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:

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro