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 🙂 |
