Introduction

Knowing the WP_Query arguments is one thing. Putting them together to solve a real problem is another. This article is a collection of complete, working query examples you can adapt: related posts, a featured slider, an upcoming events list, a filtered portfolio, and more.

Each example shows the full pattern, explains the arguments that matter, and points out the safety and performance details that keep the query correct on a real site.

You will learn how to:

  • Build common real-world queries from scratch
  • Filter by taxonomy, custom fields, and date safely
  • Keep custom queries fast and free of the usual bugs
  • Cache an expensive query so it does not run on every page load

This builds on the WP_Query reference. If an argument here is unfamiliar, look it up there for the full description.

A Reusable Pattern for Every Custom Query

Every example below follows the same safe shape. Build the arguments, run the loop on your own object, and reset afterward.

<?php
$query = new WP_Query( $args );

if ( $query->have_posts() ) {
    while ( $query->have_posts() ) {
        $query->the_post();
        // Output using template tags here.
    }
    wp_reset_postdata();
} else {
    // Optional: a "nothing found" message.
}

Keep this skeleton in mind. The only thing that changes between examples is the $args array.

A classic feature below a single post: show other posts in the same categories, excluding the current one.

<?php
$categories = wp_get_post_categories( get_the_ID() );

$args = array(
    'category__in'        => $categories,
    'post__not_in'        => array( get_the_ID() ),
    'posts_per_page'      => 4,
    'orderby'             => 'rand',
    'no_found_rows'       => true,
    'ignore_sticky_posts' => true,
);

$related = new WP_Query( $args );

What matters here:

  • category__in matches posts in any of the current post categories.
  • post__not_in excludes the post you are already reading.
  • no_found_rows is true because related posts never need pagination, so we skip the count query.
  • ignore_sticky_posts keeps sticky posts from jumping to the top of this secondary list.

Ordering by 'rand' gives variety, but it is slightly more expensive and not cacheable in a useful way. For a high-traffic site, order by date instead and cache the result.

Show hand-picked featured posts. The cleanest approach is a dedicated category or a custom field flag. Here we use a meta flag.

<?php
$args = array(
    'post_type'      => 'post',
    'posts_per_page' => 5,
    'meta_query'     => array(
        array(
            'key'     => 'featured',
            'value'   => '1',
            'compare' => '=',
        ),
    ),
    'no_found_rows'  => true,
);

$featured = new WP_Query( $args );

The meta_query matches only posts where the featured custom field equals 1. Because a slider is a fixed list, no_found_rows is true. If editors choose the order, add a custom sort field and order by it.

Example 3: Upcoming Events Sorted by Date

For an events post type that stores an event_date custom field, show only future events, soonest first.

<?php
$args = array(
    'post_type'      => 'event',
    'posts_per_page' => 10,
    'meta_query'     => array(
        array(
            'key'     => 'event_date',
            'value'   => date( 'Y-m-d' ),
            'compare' => '>=',
            'type'    => 'DATE',
        ),
    ),
    'meta_key'       => 'event_date',
    'orderby'        => 'meta_value',
    'order'          => 'ASC',
);

$events = new WP_Query( $args );

Two details make this correct:

  • type set to DATE makes the comparison treat the value as a date, not a string, so '>=' works as expected.
  • Ordering by meta_value with meta_key set to event_date sorts events chronologically. If the field stored a timestamp instead, you would use meta_value_num.

Example 4: A Filterable Portfolio by Taxonomy

Show portfolio items in a selected category from a custom taxonomy, for example a filter UI that passes the term slug in the URL.

<?php
$term = isset( $_GET['filter'] ) ? sanitize_text_field( wp_unslash( $_GET['filter'] ) ) : '';

$args = array(
    'post_type'      => 'portfolio',
    'posts_per_page' => 12,
);

if ( $term ) {
    $args['tax_query'] = array(
        array(
            'taxonomy' => 'project_type',
            'field'    => 'slug',
            'terms'    => $term,
        ),
    );
}

$portfolio = new WP_Query( $args );

Notice the input is sanitized with sanitize_text_field() and wp_unslash() before it touches the query. Never pass raw request data straight into WP_Query. The tax_query is only added when a filter is present, so the unfiltered view shows everything.

Example 5: A Paginated Custom Archive

When a custom query needs working pagination, pass the current page and render links from the query object.

<?php
$paged = max( 1, get_query_var( 'paged' ), get_query_var( 'page' ) );

$args = array(
    'post_type'      => 'book',
    'posts_per_page' => 9,
    'paged'          => $paged,
);

$books = new WP_Query( $args );

if ( $books->have_posts() ) {
    while ( $books->have_posts() ) {
        $books->the_post();
        the_title( '<h2>', '</h2>' );
    }

    echo paginate_links( array(
        'total'   => $books->max_num_pages,
        'current' => $paged,
    ) );

    wp_reset_postdata();
}

The query needs paged so each page fetches different posts, and paginate_links() needs max_num_pages so it knows where to stop. Reading both paged and page covers the static front page case.

Example 6: Posts From the Last 30 Days

Use date_query for time-based filters instead of manual date math.

<?php
$args = array(
    'posts_per_page' => 6,
    'date_query'     => array(
        array(
            'after'     => '30 days ago',
            'inclusive' => true,
        ),
    ),
);

$recent = new WP_Query( $args );

date_query accepts any relative string that strtotime() understands, so "30 days ago" works without calculating timestamps yourself.

Caching an Expensive Query

If a query is heavy and its results do not change often, do not run it on every page load. Cache the result in a transient. Cache the post IDs rather than the whole WP_Query object: IDs are tiny to store, and a follow-up query by post__in uses the (fast) object cache for the actual post data.

<?php
$key = 'kubic_popular_post_ids';
$ids = get_transient( $key );

if ( false === $ids ) {
    $ids = get_posts( array(
        'posts_per_page' => 5,
        'orderby'        => 'comment_count',
        'order'          => 'DESC',
        'no_found_rows'  => true,
        'fields'         => 'ids',
    ) );

    set_transient( $key, $ids, HOUR_IN_SECONDS );
}

$popular = new WP_Query( array(
    'post__in'            => $ids ? $ids : array( 0 ),
    'orderby'             => 'post__in',
    'ignore_sticky_posts' => true,
) );

The transient stores just the IDs for an hour. The first visitor after it expires runs the ranking query; everyone else reuses the cached IDs. The fallback array( 0 ) keeps the query from returning all posts if the list is empty. Remember to delete or refresh the transient when the data changes, for example on save_post, so the cache does not go stale.

Best Practices

  • Always sanitize any user input (query strings, form fields) before putting it into query arguments.
  • Add no_found_rows for fixed lists that never paginate, to skip the count query.
  • Set the meta type to DATE or NUMERIC when comparing dates or numbers.
  • Reset post data with wp_reset_postdata() after every loop that calls the_post().
  • Cache expensive or rarely changing queries in a transient and clear it on save_post.
  • To change an archive listing, use pre_get_posts on the main query rather than a new WP_Query in the template.

Common Mistakes

Passing raw request data into the query

Putting $_GET or $_POST values straight into WP_Query is a security risk. Always sanitize first with the appropriate function, such as sanitize_text_field() or absint() for IDs.

Querying with posts_per_page set to -1

Loading every matching post can exhaust memory on a large site. Set a real limit and paginate when you need more.

Wrong meta comparison type

Comparing a date or number without setting type leaves it as a string comparison, which produces wrong results. Always set the type for non-text meta.

Forgetting to reset after the loop

Skipping wp_reset_postdata() leaves the global post pointing at the last result, breaking template tags later on the page.

Troubleshooting

The query returns posts but in the wrong order

Check orderby and order. For custom fields, you need both orderby set to meta_value (or meta_value_num) and meta_key set to the field. Use the _num variant for numbers.

A date or number filter includes the wrong posts

Set the type option in the meta clause to DATE or NUMERIC. Without it, "100" can compare as less than "20" because the comparison is alphabetical.

Pagination on the custom query shows duplicate or missing posts

Confirm you pass paged and are not also using offset. offset overrides paging and breaks the page math.

Frequently Asked Questions

How do I exclude the current post from a query?

Use post__not_in with an array containing get_the_ID(). This is essential for related-post queries so the current post does not appear in its own list.

Can I combine taxonomy and meta filters in one query?

Yes. Add both tax_query and meta_query to the same arguments array. Each has its own relation for combining multiple clauses, and WordPress applies both.

How do I cache a query result?

Wrap the query in get_transient() and set_transient(). Store the result under a key, return the cached value when present, and refresh it on a schedule or when the data changes.

Why is my custom query slow?

The usual causes are large meta_query filters, ordering by rand, and posts_per_page set to -1. Add no_found_rows where you do not paginate, prefer taxonomies for frequent filters, and cache heavy results.

Should I use a custom query or pre_get_posts for an archive?

Use pre_get_posts to change what the main archive shows, because it keeps pagination and the template hierarchy intact. Use a new WP_Query for an additional list alongside the main content, like a sidebar or related section.

Conclusion

Most real WordPress features are just a WP_Query with the right arguments and the same safe loop around it. Once you have built a related-posts query, an events list, and a filtered archive, the pattern becomes second nature: assemble the arguments, loop on your object, reset afterward.

Two habits separate solid custom queries from fragile ones: sanitize every input, and do not make the database do more work than the feature needs. Add the performance arguments and cache the heavy queries, and your custom lists stay fast as the site grows.

Next, get_posts vs WP_Query vs query_posts helps you pick the right tool, and How to Modify Main Queries with pre_get_posts covers changing the queries WordPress builds automatically.