Contents
Introduction: What is the main query and why pre_get_posts
The WordPress main query is the WP_Query instance that WordPress constructs automatically to determine which posts (or custom post types) are displayed on the current request — for example the blog index, category archives, date archives, author archives, search results, single post pages, and custom post type archives. Modifying the main query is necessary when you want to change what the page displays without creating a separate custom WP_Query in the template.
The pre_get_posts action is the canonical, safe hook to modify WP_Query objects before the SQL is generated. It receives the WP_Query object by reference, allowing you to change query variables using query->set(key, value) or by directly editing query->query_vars. When used correctly (targeting only the intended queries) it is efficient and compatible with WordPress core and plugins.
Where pre_get_posts runs and common checks
pre_get_posts runs for every WP_Query instance WordPress creates (front-end, admin screens, REST-related queries that use WP_Query, and any plugin-created queries). Because of that, precise conditional checks are essential to avoid unintended side effects.
- is_admin(): returns true for WordPress dashboard (admin) requests. Commonly used to avoid changing admin list tables unless explicitly desired.
- query->is_main_query(): returns true for the main query on the request. Always check this when you only intend to modify the main query and not custom WP_Query instances created by themes/plugins.
- Conditional tags like is_home(), is_archive(), is_category(), is_search(), is_post_type_archive(product), is_author(), etc., determine the request context. Use them in combination with is_main_query(). Note that conditional tags only behave reliably after WP parses the query (pre_get_posts is the correct hook).
- REST_REQUEST or defined(REST_REQUEST): REST requests can trigger WP_Query in some cases. To avoid modifying queries for REST endpoints, check defined(REST_REQUEST) REST_REQUEST and early return when necessary.
Best practices (summary)
- Always check is_main_query() when you are targeting the main frontend query.
- Use is_admin() to avoid affecting admin screens unless you intend to.
- Be explicit with conditional tags (is_home, is_archive, is_category, etc.).
- Prefer query->set() to directly modifying SQL or global variables.
- Do not call query_posts() inside pre_get_posts. That function overrides the main query unsafely and is discouraged.
- When running custom WP_Query inside your pre_get_posts callback, prevent recursion by temporarily removing your pre_get_posts action or using safe guards so you do not re-trigger the same hook for the new query.
- Keep changes minimal and performant. Adding complex joins, expensive meta queries or hundreds of taxonomy terms in queries can degrade performance and break caching.
How to modify the main query: basic pattern
The basic pattern is:
add_action( pre_get_posts, my_modify_main_query ) function my_modify_main_query( query ) { // 1) Do not affect admin screens if ( is_admin() ) { return } // 2) Only modify the main query if ( ! query->is_main_query() ) { return } // 3) Check the context and set query vars if ( is_home() ) { // Example: set posts per page for blog index query->set( posts_per_page, 6 ) } }
Notes:
- Return early when not appropriate to avoid unintended side effects.
- You can place complex conditional logic inside the same function or split into multiple hooked functions with different priorities.
Common real-world examples
1) Change posts_per_page on home (blog index)
Change the number of blog posts shown on the front page blog index:
add_action( pre_get_posts, example_home_posts_per_page ) function example_home_posts_per_page( query ) { if ( is_admin() ! query->is_main_query() ) { return } if ( is_home() ) { query->set( posts_per_page, 6 ) } }
2) Exclude a category from the home page
Exclude posts from a particular category (category ID used here as example). Use category__not_in for post ID-based exclusion.
add_action( pre_get_posts, exclude_category_from_home ) function exclude_category_from_home( query ) { if ( is_admin() ! query->is_main_query() ) { return } if ( is_home() ) { query->set( category__not_in, array( 5 ) ) // Replace 5 with your category ID } }
3) Include custom post types on category archives
Category archives by default only include post. To include custom post types that support categories (e.g., news) on category archives:
add_action( pre_get_posts, add_cpt_to_category_archives ) function add_cpt_to_category_archives( query ) { if ( is_admin() ! query->is_main_query() ) { return } if ( is_category() ) { post_types = array( post, news ) // Add your CPT slug(s) query->set( post_type, post_types ) } }
Important: The custom post type should be registered to support category taxonomy (register_taxonomy_for_object_type or taxonomies when registering post type).
4) Modify search results to search only custom post types
Limit search results to a specific post type (for instance product):
add_action( pre_get_posts, search_only_products ) function search_only_products( query ) { if ( is_admin() ! query->is_main_query() ) { return } if ( is_search() ) { query->set( post_type, array( product ) ) query->set( posts_per_page, 10 ) } }
5) Order archive by a custom field (meta value numeric)
Order posts by a numeric custom field such as _price. Use meta_key orderby => meta_value_num for numeric sorting:
add_action( pre_get_posts, order_products_by_price ) function order_products_by_price( query ) { if ( is_admin() ! query->is_main_query() ) { return } if ( is_post_type_archive( product ) ) { query->set( meta_key, _price ) query->set( orderby, meta_value_num ) query->set( order, ASC ) query->set( posts_per_page, 20 ) } }
6) Add meta_query conditions (filter by custom field)
Apply a meta_query to show items with a custom field value greater than or equal to 100:
add_action( pre_get_posts, filter_products_by_min_price ) function filter_products_by_min_price( query ) { if ( is_admin() ! query->is_main_query() ) { return } if ( is_post_type_archive( product ) ) { meta_query = array( array( key => _price, value => 100, compare => >=, type => NUMERIC, ), ) query->set( meta_query, meta_query ) } }
7) Respect pagination (paged) when modifying posts_per_page or offsets
When you use offsets with paged you must rewrite pagination logic. Better approach: use paged and posts_per_page. Example uses paged from the query itself:
add_action( pre_get_posts, custom_pagination_example ) function custom_pagination_example( query ) { if ( is_admin() ! query->is_main_query() ) { return } if ( is_home() ) { // Read paged directly from query paged = max( 1, (int) query->get( paged ) ) query->set( posts_per_page, 6 ) query->set( paged, paged ) } }
If using an offset, remember offset breaks pagination by default. The pattern is to calculate an adjusted paged or use the found_posts filter to adjust total pages avoid offset if you need standard pagination unless you also handle the pagination math.
pre_get_posts runs in admin too. Example: show only posts by the current user on the edit.php screen for users who cannot edit_others_posts:
add_action( pre_get_posts, admin_filter_posts_list_for_authors ) function admin_filter_posts_list_for_authors( query ) { global pagenow if ( ! is_admin() ! query->is_main_query() ) { return } if ( pagenow === edit.php ! current_user_can( edit_others_posts ) ) { query->set( author, get_current_user_id() ) } }
Advanced patterns and gotchas
Recursion and generating extra queries
If your callback runs additional WP_Query instances or functions that internally create WP_Query (for example functions that fetch related posts), you can accidentally trigger your pre_get_posts callback again for those internal queries, causing unexpected behavior or recursion. Mitigations:
- Temporarily remove the action while running your internal query: remove_action(pre_get_posts, your_callback) run query add_action(pre_get_posts, your_callback)
- Check for a flag on the current query (e.g., query->get(is_internal_query)) and set it on internal queries.
- Use strict is_main_query() checks — most internal WP_Query instances are not main queries.
Avoid query_posts()
query_posts() replaces the main query and causes undesirable side effects including breaking pagination and interfering with global state. pre_get_posts is the recommended method for altering the main query safely.
Performance considerations
- Meta queries and complex taxonomy queries can add JOINs — minimize complexity where possible and add proper indexes for large data sets.
- Caching: Changing queries changes the SQL and therefore cache keys. Ensure object and page caching systems are compatible with your changes.
- Prefer using tax_query with term IDs where possible rather than long lists of names that require additional lookups.
parse_query vs pre_get_posts
Both parse_query and pre_get_posts receive the WP_Query object and allow changes. parse_query runs earlier in the WP_Query lifecycle (it runs inside WP_Query::parse_query), while pre_get_posts is a more widely recommended hook for modifying queries before get_posts() runs and producing the SQL. pre_get_posts is generally used on the front-end for altering main queries parse_query is sometimes used for low-level parsing or admin customizations. For most use-cases pre_get_posts is clearer and recommended.
Security and sanitization
When setting query variables from user input (for example GET parameters), sanitize and validate values. Accept only expected values and cast to the correct type to prevent injection into SQL or unexpected behavior.
add_action( pre_get_posts, safe_filter_by_price_param ) function safe_filter_by_price_param( query ) { if ( is_admin() ! query->is_main_query() ) { return } if ( is_post_type_archive( product ) isset( _GET[min_price] ) ) { min_price = intval( _GET[min_price] ) // sanitize as integer if ( min_price > 0 ) { meta_query = array( array( key => _price, value => min_price, compare => >=, type => NUMERIC, ), ) query->set( meta_query, meta_query ) } } }
Debugging tips
- Use var_dump or error_log to inspect query->query_vars inside your callback (but avoid printing directly to the page in production).
- Examine the generated SQL using query->request after get_posts() has run to verify what is executed. For faster debugging, replicate the query variables in a WP_Query instance and inspect SQL on that instance.
- Disable plugins/themes selectively to identify conflicts if a query seems altered unexpectedly.
Edge cases and special notes
- REST API: Some WP REST requests that use WP_Query may trigger pre_get_posts. If you do not want to affect REST responses, early return on defined(REST_REQUEST) REST_REQUEST or use the function function_exists(wp_is_json_request) and its checks depending on WP version. Another safe condition: if ( defined(REST_REQUEST) REST_REQUEST ) { return }
- Admin-ajax: wp_ajax requests sometimes use WP_Query. Exclude via wp_doing_ajax() or is_admin() checks if needed.
- Be mindful of hierarchical conditional tags like is_archive() returning true on more specific archive types (is_category, is_tag, is_post_type_archive). Order conditionals from specific to general when you have overlapping logic.
- When changing the post_type on search or archive, be mindful that templates used (template hierarchy) may change and that rewrites/permalinks may differ.
Checklist before deployment
- Confirm you used is_main_query() when targeting the main query.
- Confirm you excluded admin and REST requests if those should not be affected.
- Validate and sanitize any input-derived values (GET/POST).
- Test pagination, especially if you altered posts_per_page, offsets, or order by.
- Check queries with slow query logging or query monitor plugin to ensure no performance regressions.
- Ensure that any added post types are registered to support taxonomies you include in the query.
Further reading and references
Official documentation for the hook: https://developer.wordpress.org/reference/hooks/pre_get_posts/
This article covered conceptual background, safety checks, many examples (posts_per_page, post_type changes, meta_query, orderby, admin filtering), performance considerations, recursion prevention, and security. Use the patterns above as templates and adapt the conditional logic and query_vars to your project requirements.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |