How to customize search with WP_Query and meta_query in WordPress

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

  1. Duplicate posts: Add DISTINCT via posts_distinct filter or limit JOINs.
  2. Slow queries: Inspect queries via Query Monitor plugin. Consider indexing or using alternatives described above.
  3. Unexpected results when comparing numbers: Ensure type => NUMERIC when comparing numeric meta values.
  4. 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.
  5. 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 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *