“Only products under $30.” “Only what’s in stock.”—meta_query is how you filter posts by custom field values. The basic shape is the same nested structure as tax_query.
<?php
$query = new WP_Query([
'post_type' => 'shohin',
'meta_query' => [
[
'key' => 'price', // the field name
'value' => 3000, // the value to compare against
'compare' => '<=', // how to compare
'type' => 'NUMERIC', // the value's type (required for numbers)
],
],
]);
?>The most important part is type. Forget it and numeric comparisons come out wrong (why, below).
What the four keys mean
| Key | Role | Common values |
|---|---|---|
key | The custom field’s name | 'price', 'event_date' |
value | The value to compare against | Number, string or array |
compare | How to compare | '=' (default), '!=', '>', '>=', '<', '<=', 'LIKE', 'IN', 'NOT IN', 'BETWEEN', 'EXISTS', 'NOT EXISTS' |
type | The value’s type | 'CHAR' (default), 'NUMERIC', 'DATE', 'DATETIME', 'DECIMAL' |
Why type is necessary
WordPress stores custom field values as strings, without distinguishing types. So without a type, comparisons follow string rules too.
- Compared as strings—
'100'is smaller than'99'(because it compares the leading 1 against 9) - Add
'type' => 'NUMERIC'—it compares as numbers, so100 > 99
Whenever you handle numbers—price, stock count, score—always include 'type' => 'NUMERIC'. If decimals are involved, use 'DECIMAL'.
compare in practice
// stock of 1 or more (in stock)
['key' => 'stock', 'value' => 0, 'compare' => '>', 'type' => 'NUMERIC'],
// between 1000 and 3000
['key' => 'price', 'value' => [1000, 3000], 'compare' => 'BETWEEN', 'type' => 'NUMERIC'],
// size is either S or M
['key' => 'size', 'value' => ['S', 'M'], 'compare' => 'IN'],
// partial match (the note contains "limited")
['key' => 'note', 'value' => 'limited', 'compare' => 'LIKE'],
// only posts that have something in that field
['key' => 'price', 'compare' => 'EXISTS'],
// only posts where that field is empty (or unset)
['key' => 'price', 'compare' => 'NOT EXISTS'],BETWEEN and IN require value to be an array.
Combining several conditions
relation decides how they’re joined (AND when omitted).
'meta_query' => [
'relation' => 'AND',
['key' => 'price', 'value' => 3000, 'compare' => '<=', 'type' => 'NUMERIC'],
['key' => 'stock', 'value' => 0, 'compare' => '>', 'type' => 'NUMERIC'],
],Nest them and you can write “(A or B) and C” too.
'meta_query' => [
'relation' => 'AND',
[
'relation' => 'OR',
['key' => 'is_new', 'value' => '1'],
['key' => 'is_sale', 'value' => '1'],
],
['key' => 'stock', 'value' => 0, 'compare' => '>', 'type' => 'NUMERIC'],
],Sorting by custom field value
Separately from filtering, custom fields can drive sorting too. It’s a combination of meta_key and orderby.
// cheapest first
$query = new WP_Query([
'post_type' => 'shohin',
'meta_key' => 'price',
'orderby' => 'meta_value_num', // sort as numbers
'order' => 'ASC',
]);'orderby' => 'meta_value_num'—sort as numbers (price, score, stock)'orderby' => 'meta_value'—sort as strings (names, dates inYmdformat)
Sorting by two criteria (named conditions)
Give your meta_query conditions names and you can refer to them from orderby. That gives multi-level sorting like “featured first, then cheapest.”
$query = new WP_Query([
'post_type' => 'shohin',
'meta_query' => [
'featured' => ['key' => 'is_featured', 'compare' => 'EXISTS'],
'price' => ['key' => 'price', 'compare' => 'EXISTS', 'type' => 'NUMERIC'],
],
'orderby' => [
'featured' => 'DESC',
'price' => 'ASC',
],
]);The array keys (featured, price) become name tags for the conditions, and orderby sets their order.
Filtering by a date field
ACF (SCF) date fields are stored by default as an Ymd string like 20260725. In that format, comparisons work correctly even as strings, so you can use them as-is.
// only events from today onward, soonest first
$query = new WP_Query([
'post_type' => 'event',
'meta_key' => 'event_date',
'orderby' => 'meta_value',
'order' => 'ASC',
'meta_query' => [
[
'key' => 'event_date',
'value' => date('Ymd'),
'compare' => '>=',
],
],
]);If the save format has hyphens, like 2026-07-25, add 'type' => 'DATE' for the comparison.
Example: pairing it with a filter form
A pattern that takes a URL parameter (?max_price=3000) and builds conditions from it.
<?php
$args = ['post_type' => 'shohin', 'posts_per_page' => 12];
if (!empty($_GET['max_price'])) {
$args['meta_query'][] = [
'key' => 'price',
'value' => (int) $_GET['max_price'], // always convert to a number
'compare' => '<=',
'type' => 'NUMERIC',
];
}
$query = new WP_Query($args);
?>When it gets slow, revisit the design
Post meta is stored as “one value per row,” so the more conditions you add, the more database joins pile up and the slower it gets.
- The filtering axis is fixed (season, brand, category) → making it a custom taxonomy and using
tax_queryis faster - The filtering axis is a numeric range (price, area) →
meta_queryis the right fit
“Fields used for filtering in listings become taxonomies; fields that are only displayed on the single page stay custom fields”—that’s a design decision that pays off later.
TipsFiltering by category and taxonomy with tax_queryHow to write tax_query to filter by taxonomySummary
meta_queryis a set of four:key/value/compare/type. Conditions go in an array inside an array- Numeric comparisons require
'type' => 'NUMERIC'(the default is string comparison, which breaks ordering) - Sort with
meta_keyplusorderby: meta_value_num. Watch out—posts without a value disappear - If the filtering axis is fixed, making it a taxonomy and using
tax_queryis faster
Once you’re comfortable writing conditions, “the data exists but I can’t list it” stops happening.