Excluding Posts by meta_value on a CPT Archive Without Corrupting Pagination

The symptom: the filter works, the page count does not

You add a pre_get_posts callback to a custom post type archive. The intent is simple: hide posts whose _editorial_status meta equals archived. The first page looks right. Then someone clicks page 3 and gets a page of posts that should have been excluded, or a 404 where a page should exist, or a pagination link that points past the last real page.

This is not a display bug. It is a query-shape bug. The exclusion changed the number of rows the database returns, but the pagination math was computed from a different shape than the one the template renders. The fix is not to hide rows in the loop. The fix is to make the main query itself return the correct set, and then verify that found_posts and max_num_pages agree with what the template shows.

Trace: where the hook sits in the query lifecycle

pre_get_posts fires after the query variable object is created but before the actual query is run. That is the documented contract, and it matters because it means you are modifying query vars, not filtering results. The hook receives the WP_Query instance by reference.

Inside the callback, use the method on the passed object, not the global conditional function. The WordPress reference is explicit: conditional tags should use the method versions within the passed instance, such as $query->is_main_query() instead of is_main_query(), because the global functions test against the global $wp_query instance, not the passed one. The is_main_query() function documentation goes further and calls the global function inside pre_get_posts circular.

That single detail explains a common failure: a callback that calls is_main_query() without the object method can evaluate against the wrong query, or trigger a _doing_it_wrong() notice, and the guard silently fails open. The filter then applies to a secondary loop in a sidebar or a related-posts block, and the archive pagination is computed from a query that never received the exclusion.

The scoping guard, in order

The documented guidance is to use conditionals so you do not unintentionally change queries for custom loops in sidebars, footers, or elsewhere. For a CPT archive, the guard has three parts and the order matters:

  1. ! is_admin() — do not touch admin screens.
  2. $query->is_main_query() — target the primary loop only.
  3. $query->is_post_type_archive( 'book' ) — target the specific archive.

The third check is a method on the query object. The hook reference shows the pattern with is_post_type_archive( 'movie' ) inside a pre_get_posts callback, so the method form is the documented shape. If you are unsure whether a conditional is safe at this point in the lifecycle, inspect $query->query_vars directly rather than guessing.

Root cause: a meta_query is a JOIN, not a display filter

WP_Meta_Query is the helper that generates JOIN and WHERE subclauses attached to the primary SQL query to filter results by metadata. When you add a meta_query clause in pre_get_posts, you are changing the SQL that produces both the rows and the count. That is the correct tool, but it is also why a malformed clause produces pagination drift rather than a visible error.

The compare argument accepts =, !=, >, >=, <, <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN, NOT BETWEEN, EXISTS, and NOT EXISTS. The choice between != and NOT EXISTS is not cosmetic. It determines whether posts that lack the meta key entirely are included or dropped, and that changes the count.

Why offset is the wrong tool here

The hook reference states plainly that using the offset argument in any WordPress query can break pagination, and that preserving pagination with offset requires handling pagination manually. Offset shifts the result window; it does not remove rows. If you try to exclude archived posts by skipping a fixed number of results, the window drifts as the excluded set changes, and the page count no longer matches the rows. Use a meta_query clause to remove rows from the set, and keep offset out of the main archive query.

The fix: append, do not overwrite, and choose the comparison deliberately

The main query may already carry a meta_query from another plugin or from the theme. Overwriting it with $query->set( 'meta_query', array( ... ) ) discards whatever was there. Read the existing value, normalize it to an array, append your clause, and set it back. The hook reference includes a user-contributed example that does exactly this normalization before appending.

Here is a labeled example. It is illustrative, not a report of a tested deployment.

add_action( 'pre_get_posts', 'jv_exclude_archived_books' );

function jv_exclude_archived_books( $query ) {
    if ( is_admin() ) {
        return;
    }
    if ( ! $query->is_main_query() ) {
        return;
    }
    if ( ! $query->is_post_type_archive( 'book' ) ) {
        return;
    }

    $meta_query = $query->get( 'meta_query' );
    if ( ! is_array( $meta_query ) ) {
        $meta_query = array();
    }

    $meta_query[] = array(
        'key'     => '_editorial_status',
        'value'   => 'archived',
        'compare' => '!=',
    );

    $query->set( 'meta_query', $meta_query );
}

Two decisions in that snippet deserve scrutiny.

Comparison choice. With compare => '!=', the query returns posts whose meta value is not archived. Posts that have no _editorial_status row at all are a separate case. If your editorial workflow guarantees every book has the key, != is sufficient. If some books predate the workflow, decide explicitly whether they belong on the archive. NOT EXISTS is the operator for the missing-key case, and it is a different query shape. Do not assume the two are interchangeable; verify the count for each.

Append, do not replace. The normalization block is not defensive decoration. If another callback already set a meta_query and you overwrite it, you have silently removed a filter that may have been controlling which posts appear. The archive then shows more posts than the other filter intended, and the pagination count reflects the larger set.

What the count actually means

WP_Query exposes found_posts as the total number of posts found matching the current query parameters, and max_num_pages as the result of found_posts / posts_per_page. Those are the numbers the pagination links are built from. If your meta_query clause is applied to the main query, both numbers reflect the filtered set. If it is applied to a secondary query, or if the guard fails and the clause lands on the wrong query, the template may render filtered rows while the pagination links were computed from the unfiltered main query. That is the drift.

Verification: prove the count before you trust the page

Do not verify by clicking through pages and eyeballing. Verify the numbers.

1. Confirm the hook fires once, on the main query

On a staging copy, dump the query vars inside the callback and log the call count. A callback that fires more than once for the archive request, or fires for a query whose post_type is not your CPT, is mis-scoped. The hook reference warns that without conditionals you can change custom loops in sidebars, footers, or elsewhere; the log is how you catch that.

function jv_debug_archive_query( $query ) {
    if ( ! $query->is_main_query() ) {
        return;
    }
    if ( ! $query->is_post_type_archive( 'book' ) ) {
        return;
    }
    error_log( 'main archive query_vars: ' . wp_json_encode( $query->query_vars ) );
}
add_action( 'pre_get_posts', 'jv_debug_archive_query', 999 );

Run it with WP_DEBUG_LOG enabled and read the log. You are looking for one entry per archive request, with the expected post_type and paged values.

2. Compare found_posts before and after

In the template, or in a temporary mu-plugin, print global $wp_query; echo $wp_query->found_posts; and echo $wp_query->max_num_pages; on the archive. Record both with the filter disabled and enabled. The difference should equal the number of posts you intended to exclude. If it does not, the clause is not matching what you think it is matching.

3. Check the SQL shape

Query Monitor exposes the main query SQL. Look at the JOIN and WHERE clauses generated for your meta clause. A != comparison and a NOT EXISTS comparison produce different JOIN shapes, and the row count difference between them is the set of posts with no meta row. If the JOIN is not what you expected, the comparison is not doing what you intended.

4. Confirm the pagination boundary

Request the last page directly by URL, for example /book/page/4/, and confirm it returns rows rather than a 404. Then request one page past the last, and confirm it 404s. If the last real page 404s, max_num_pages is too low. If a page past the end returns rows, it is too high. Both point back to the count, not the template.

What this pattern does not cover

This article is scoped to the main query on a CPT archive. It does not claim anything about how the REST API or the block editor’s query loop handles a pre_get_posts modification, because the retrieved documentation does not establish that behavior. If your archive is also consumed by a REST endpoint or a query loop block, verify those contexts separately rather than assuming the hook applies uniformly.

It also does not claim a performance outcome. Adding a meta clause changes the SQL; whether that is faster or slower depends on indexes and data volume, and the documentation does not provide a number to cite.

FAQ

Why does is_main_query() behave differently inside pre_get_posts?

The global function compares against the global $wp_query, not the query object passed to your callback. The hook reference says to use the method on the passed instance, and the is_main_query() documentation calls the global function circular in this context. Use $query->is_main_query().

Should I use != or NOT EXISTS to exclude a meta value?

They answer different questions. != excludes posts whose meta value equals the target; NOT EXISTS targets posts that lack the key. If some posts have no meta row, the two produce different result sets and different pagination counts. Decide which set you mean and verify found_posts for that choice.

Can I just use offset to skip the excluded posts?

No. The hook reference states that using offset in any WordPress query can break pagination and that preserving pagination with offset requires manual handling. Offset shifts the window; it does not remove rows from the set.

Why did my exclusion stop working after another plugin update?

A likely cause is that another callback set a meta_query and your callback overwrote it, or the reverse. Read the existing meta_query, normalize it to an array, append your clause, and set it back. Then re-check found_posts.

Where should I look first when the page count is wrong?

At found_posts and max_num_pages on the main query, not at the template. max_num_pages is found_posts / posts_per_page. If the count is wrong, the pagination links are wrong, and the template is doing what it was told.

If the archive is returning nothing at all rather than the wrong count, the failure is earlier in the request and a different diagnostic sequence applies; see what to fix first when a new WordPress site says nothing found.

Sources