Contents
Introduction
This tutorial explains in exhaustive detail how to customize WordPress search using WP_Query and meta_query. It covers the default search behavior, how to search postmeta, how to combine title/content search with meta queries, how to customize the main search via pre_get_posts, advanced meta_query features (nested clauses, comparisons, types), performance considerations, precautions, and real-world examples including AJAX search. All code examples are ready to copy into your theme or plugin.
Overview: WP_Query, search parameter and default behavior
WP_Query is the WordPress class used to query posts. The main search parameter is s. By default, s searches post_title, post_content and post_excerpt. It does not search postmeta (wp_postmeta) or taxonomy terms. To search custom fields (postmeta) you must add meta_query clauses or modify SQL directly using filters.
Default example (searching title/content)
args = array( s => blue widget, post_type => post, posts_per_page => 10, paged => 1, ) query = new WP_Query( args )
meta_query basics
meta_query is an array of clauses. Each clause is an associative array with keys: key, value, compare, and type. Clauses are joined by a top-level relation which is AND by default. Use meta_query to filter posts by custom fields.
meta_query clause structure
key | The meta_key to query (string) |
value | The value to match. May be array for IN, NOT IN or BETWEEN |
compare | Comparison operator: =, !=, >, >=, <, <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN, NOT BETWEEN, EXISTS, NOT EXISTS, REGEXP, RLIKE |
type | Data type for comparison: NUMERIC, BINARY, CHAR, DATE, DATETIME, DECIMAL, SIGNED, UNSIGNED |
Simple meta_query example
Query posts where meta key color equals blue.
args = array( post_type => product, meta_query => array( array( key => color, value => blue, compare => =, type => CHAR, ), ), ) query = new WP_Query( args )
Advanced meta_query options and patterns
Multiple clauses and relation
Combine clauses with relation => AND or OR. Example: color is blue AND price <= 50.
args = array( post_type => product, meta_query => array( relation => AND, array( key => color, value => blue, compare => =, ), array( key => price, value => 50, compare => <=, type => NUMERIC, ), ), ) query = new WP_Query( args )
Nested meta_query (grouping)
You can nest meta_query groups by including named arrays that themselves have relation and sub-clauses. This is necessary for more complex boolean logic.
args = array( post_type => product, meta_query => array( relation => AND, array( key => status, value => published, compare => =, ), // Nested OR group: (color = blue OR size = large) array( relation => OR, array( key => color, value => blue, compare => =, ), array( key => size, value => large, compare => =, ), ), ), ) query = new WP_Query( args )
Using BETWEEN, IN and EXISTS
Common powerful comparisons:
IN | value is array matches if meta_value is any of the array items |
BETWEEN | value should be array( low, high ) |
EXISTS / NOT EXISTS | Check whether meta key exists (value not required) |
// BETWEEN example args = array( post_type => event, meta_query => array( array( key => event_date, value => array( 2025-01-01, 2025-12-31 ), compare => BETWEEN, type => DATE, ), ), ) // EXISTS example args2 = array( post_type => product, meta_query => array( array( key => sku, compare => EXISTS, ), ), )
Comparing numerically and ordering
To sort by numeric meta value, use meta_key and orderby => meta_value_num. Always set type => NUMERIC in meta_query when comparing numbers.
args = array( post_type => product, meta_key => price, orderby => meta_value_num, order => ASC, meta_query => array( array( key => price, value => array( 10, 100 ), compare => BETWEEN, type => NUMERIC, ), ), ) query = new WP_Query( args )
Search meta values (search term inside meta)
To search for a substring inside a meta value, use LIKE with wildcards. meta_query will automatically prepare the value, but you should include % wildcards yourself by using the appropriate compare. For safety, use sanitize_text_field on user input.
term = blue args = array( post_type => product, meta_query => array( array( key => description, value => % . term . %, compare => LIKE, ), ), ) query = new WP_Query( args )
Combining title/content search with postmeta search
WordPress does not natively search meta when using the s parameter. To search both main content (title/content) and meta fields and return posts that match either, you can:
- Use posts_clauses, posts_join and posts_where filters to extend the search SQL to include meta_value comparisons.
- Run two queries (one for content, one for meta) and merge results (may need to preserve ordering and avoid duplicates).
Filter-based approach: search title/content or meta_value
The example below modifies the search WHERE clause to look for the search term in post_title, post_content and a given meta_key. It uses wpdb->prepare to avoid SQL injection and ensures DISTINCT to avoid duplicate rows when joining postmeta.
add_filter( posts_join, my_search_join ) add_filter( posts_where, my_search_where ) add_filter( posts_distinct, my_search_distinct ) function my_search_join( join ) { global wpdb if ( is_search() ) { // Join postmeta so we can search its values join .= LEFT JOIN {wpdb->postmeta} AS pm_search ON ({wpdb->posts}.ID = pm_search.post_id) } return join } function my_search_where( where ) { global wpdb if ( is_search() ! empty( get_query_var( s ) ) ) { search = get_query_var( s ) // Prepare safe wildcarded search term like = % . wpdb->esc_like( search ) . % // Add meta_value search OR existing search conditions where .= wpdb->prepare( OR (pm_search.meta_value LIKE %s), like ) } return where } function my_search_distinct( distinct ) { if ( is_search() ) { return DISTINCT } return distinct }
Notes:
- Use wpdb->esc_like and wpdb->prepare when injecting user search terms into SQL.
- LEFT JOIN may return duplicates adding DISTINCT resolves this.
- This approach will search all meta keys (joined table). If you need specific meta_key, add a condition in JOIN (e.g., pm_search.meta_key = your_key).
Customizing the main search: pre_get_posts
To alter the main query (the search results page) without creating new WP_Query objects, use the pre_get_posts hook. This allows you to read GET parameters from a custom search form (e.g., min_price, max_price, color, taxonomy filters) and modify query vars before the query runs.
Search form example (HTML)
A simple custom search form that sends query vars on GET.
pre_get_posts handling example
Read the GET values, sanitize them, and alter the main querys meta_query and other params. This example restricts products by price range and optionally removes results that are drafts.
add_action( pre_get_posts, my_modify_main_search ) function my_modify_main_search( query ) { if ( ! query->is_main_query() ! query->is_search() is_admin() ) { return } // Sanitize GET inputs min_price = isset( _GET[min_price] ) ? floatval( _GET[min_price] ) : max_price = isset( _GET[max_price] ) ? floatval( _GET[max_price] ) : meta_query = array() if ( min_price !== max_price !== ) { meta_query[] = array( key => price, value => array( min_price, max_price ), compare => BETWEEN, type => NUMERIC, ) } elseif ( min_price !== ) { meta_query[] = array( key => price, value => min_price, compare => >=, type => NUMERIC, ) } elseif ( max_price !== ) { meta_query[] = array( key => price, value => max_price, compare => <=, type => NUMERIC, ) } if ( ! empty( meta_query ) ) { query->set( meta_query, meta_query ) } // Example: show only products query->set( post_type, product ) query->set( posts_per_page, 12 ) }
Preventing duplicate posts when multiple meta_query clauses create joins
Every meta_query clause usually creates a JOIN to wp_postmeta. Multiple JOINs can cause the same post to appear multiple times in the result set. Use DISTINCT to ensure unique posts.
add_filter( posts_distinct, my_posts_distinct ) function my_posts_distinct( distinct ) { if ( is_search() ) { return DISTINCT } return distinct }
Searching serialized data (e.g., arrays stored as serialized strings)
If your meta is stored as a PHP-serialized array, searching for one value inside it is possible with LIKE but is brittle. Example: searching for a value in serialized array
term = blue args = array( post_type => product, meta_query => array( array( key => attributes_serialized, value => . term . , // quotes used by PHP serialize compare => LIKE, ), ), ) query = new WP_Query( args )
Better approach: store searchable values in separate meta keys or as taxonomies so queries are reliable and fast.
Performance considerations and optimizations
meta_query generates JOINs and WHERE conditions on wp_postmeta which can be large and slow. Consider these optimizations:
- Limit the number of meta_query clauses: each clause usually causes a JOIN
- Use specific meta_key conditions: include meta_key checks to reduce rows joined
- Add indexes: You can add indexes to wp_postmeta (meta_key and meta_value) but be cautious adding indexes on meta_value is not always helpful and can bloat the table. Example SQL below.
- Use custom tables: For heavy read queries on structured data (like ecommerce attributes), consider a custom table optimized for queries instead of wp_postmeta.
- Cache results: Use transients or object cache to store common queries.
- Avoid searching serialized fields: store data normalized or in taxonomies for faster filtering.
Example SQL to add an index (use with caution)
Note: Always backup your database before altering schema. Adding an index on meta_key is common indexing meta_value is typically not recommended for long text.
ALTER TABLE wp_postmeta ADD INDEX meta_key_idx (meta_key(191)) -- If using meta_value and small values, you can add a prefix index: ALTER TABLE wp_postmeta ADD INDEX meta_value_idx (meta_value(191))
Security: sanitization and preparing queries
Never trust GET/POST input. When using WP_Query, sanitize values using sanitize_text_field, intval, floatval, or more specific sanitizers. When modifying SQL using wpdb, always use wpdb->prepare and wpdb->esc_like to prevent SQL injection.
Examples: full practical cases
1) Price range keyword search in title or description (pre_get_posts)
add_action( pre_get_posts, search_products_with_price_and_keyword ) function search_products_with_price_and_keyword( query ) { if ( ! query->is_main_query() ! query->is_search() is_admin() ) { return } keyword = isset( _GET[s] ) ? sanitize_text_field( _GET[s] ) : min = isset( _GET[min_price] ) ? floatval( _GET[min_price] ) : max = isset( _GET[max_price] ) ? floatval( _GET[max_price] ) : meta_query = array() if ( min !== max !== ) { meta_query[] = array( key => price, value => array( min, max ), compare => BETWEEN, type => NUMERIC, ) } elseif ( min !== ) { meta_query[] = array( key => price, value => min, compare => >=, type => NUMERIC, ) } elseif ( max !== ) { meta_query[] = array( key => price, value => max, compare => <=, type => NUMERIC, ) } if ( ! empty( meta_query ) ) { query->set( meta_query, meta_query ) } query->set( post_type, product ) query->set( posts_per_page, 12 ) }
2) Search across title OR a specific meta_key (using posts_clauses)
add_filter( posts_clauses, search_title_or_meta_clauses, 10, 2 ) function search_title_or_meta_clauses( clauses, wp_query ) { global wpdb if ( ! wp_query->is_search() ) { return clauses } search = wp_query->get( s ) if ( empty( search ) ) { return clauses } like = % . wpdb->esc_like( search ) . % // Only join the specific meta_key we want to search clauses[join] .= wpdb->prepare( LEFT JOIN {wpdb->postmeta} AS pm_search ON ({wpdb->posts}.ID = pm_search.post_id AND pm_search.meta_key = %s) , subtitle ) // Extend WHERE to include meta_value clauses[where] = preg_replace( /(s . preg_quote( wpdb->posts . .post_title, / ) . s LIKEs([^] )s)/, ({wpdb->posts}.post_title LIKE 1 OR pm_search.meta_value LIKE %s), clauses[where] ) // Ensure the prepared like term is present in the SQL use str_replace safe method // Append OR pm_search.meta_value LIKE ... if replacement didnt occur if ( strpos( clauses[where], pm_search.meta_value ) === false ) { clauses[where] .= wpdb->prepare( OR pm_search.meta_value LIKE %s , like ) } else { // Replace placeholder token if needed (this uses wpdb->prepare below when executing) clauses[where] = str_replace( %s, wpdb->prepare( %s, like ), clauses[where] ) } // DISTINCT to avoid duplicates clauses[distinct] = DISTINCT return clauses }
This example demonstrates a practical way to extend the search to include one meta_key (subtitle). For multiple meta keys or complex OR logic, adapt joins and where conditions carefully and always use wpdb->prepare.
3) AJAX live search that looks in title and a meta field
This example shows the frontend JavaScript (fetch/AJAX) and a PHP handler for the AJAX request (nonces and capability checks omitted for brevity — always validate nonces in production).
// JavaScript: send AJAX request to admin-ajax.php const input = document.getElementById(search-input) input.addEventListener(input, function() { const term = input.value.trim() if ( term.length < 2 ) return fetch( ajaxurl, { method: POST, credentials: same-origin, headers: { Content-Type: application/x-www-form-urlencoded charset=UTF-8 }, body: new URLSearchParams({ action: my_live_search, s: term, nonce: my_ajax_object.nonce }) } ) .then( res => res.json() ) .then( data => { // Render suggestions console.log( data ) } ) })
// PHP: AJAX handler add_action( wp_ajax_nopriv_my_live_search, my_live_search ) add_action( wp_ajax_my_live_search, my_live_search ) function my_live_search() { // Check nonce and permission (omitted here) term = isset( _POST[s] ) ? sanitize_text_field( wp_unslash( _POST[s] ) ) : if ( empty( term ) ) { wp_send_json_error( No term ) } args = array( post_type => array( post, product ), posts_per_page => 8, // Well use posts_clauses filter to search meta too (see earlier example), s => term, ) query = new WP_Query( args ) results = array() if ( query->have_posts() ) { foreach ( query->posts as post ) { results[] = array( ID => post->ID, title => get_the_title( post ), link => get_permalink( post ), ) } } wp_send_json_success( results ) }
When to avoid meta_query and alternatives
meta_query is convenient, but for high-traffic or complex queries consider alternatives:
- Use taxonomies: For attributes with limited unique values (color, size), taxonomies are much faster to query.
- Custom tables: A normalized custom table with proper indexes is ideal for high-performance filtering (e.g., product catalogs).
- Search engines: For full-text search across many fields and faceted search, use Elasticsearch, Algolia, or other search-as-a-service.
- Object caching or transients: Cache expensive query results and invalidate on update.
Troubleshooting common issues
- Duplicate posts: Add DISTINCT via posts_distinct filter or limit JOINs.
- Slow queries: Inspect queries via Query Monitor plugin. Consider indexing or using alternatives described above.
- Unexpected results when comparing numbers: Ensure type => NUMERIC when comparing numeric meta values.
- No results when searching meta: Confirm the meta_key exists and values are stored as expected. If serialized, adapt the search accordingly but prefer non-serialized storage.
- Sorting by meta key fails: Use meta_key and set orderby => meta_value or meta_value_num depending on type.
Summary checklist
- Use WP_Query meta_query clauses to filter by meta keys.
- Use type to control how comparisons are performed (NUMERIC, DATE, etc.).
- Use pre_get_posts to modify the main search query.
- To search both title/content and meta, extend SQL via posts_join/posts_where or perform multiple queries and merge.
- Always sanitize user input and use wpdb->prepare when building raw SQL.
- Watch performance: meta_query causes JOINs consider taxonomies, custom tables, or external search services for scale.
Further reading
Refer to the WordPress developer reference for WP_Query, WP_Meta_Query and hooks like pre_get_posts, posts_clauses and posts_join for more details. Example links:
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |