LW IT Solutions
« Blog Overview /WordPress Plugins & Tricks / meta_query in WP_Query: How Each Clause Becomes...

meta_query in WP_Query: How Each Clause Becomes a Join and What It Costs

meta_query in WP_Query: How Each Clause Becomes a Join and What It Costs
Contents
  1. What WP_Query Builds Out of the Array
  2. The Index That Does Not Exist
  3. The Clauses That Cost More Than the Others
  4. Where the Filter Belongs Instead
  5. Measuring Instead of Guessing
  6. Sources

A page that filters posts by three custom fields is one array in PHP and four joins in SQL. The array reads like a filter; the SQL reads like a table being visited once for every condition, because that is exactly what it is.

The interesting part is not that it is slow – most sites never grow large enough to care. It is that the cost is invisible in the code that causes it, and that it grows with two things at once: the number of clauses and the size of a table nobody looks at.

A staircase of query stages: the base query reads 4800 rows, each of three meta_query joins adds 192000 rows, and the same filter as a taxonomy reads 5120
The same result set, four ways of arriving at it. The bar for the base query is not missing; it is three pixels wide.

What WP_Query Builds Out of the Array

Each clause in meta_query gets its own alias on wp_postmeta and its own join condition. Two clauses, two aliases; five clauses, five. The table is joined to itself as many times as there are conditions, and every one of those joins carries the full weight of the table behind it.

SELECT wp_posts.ID FROM wp_posts
  INNER JOIN wp_postmeta pm1 ON wp_posts.ID = pm1.post_id
  INNER JOIN wp_postmeta pm2 ON wp_posts.ID = pm2.post_id
  INNER JOIN wp_postmeta pm3 ON wp_posts.ID = pm3.post_id
WHERE post_type = 'post' AND post_status = 'publish'
  AND ( pm1.meta_key = '_region'  AND pm1.meta_value = 'north' )
  AND ( pm2.meta_key = '_level'   AND pm2.meta_value = 'b' )
  AND ( pm3.meta_key = '_active'  AND pm3.meta_value = '1' )
ORDER BY wp_posts.post_date DESC

Nothing here is wrong. It is the literal translation of what was asked for, and the database will answer it correctly – after reading, on a modest site, something in the order of half a million rows to return forty.

The Index That Does Not Exist

wp_postmeta carries three indexes: the primary key on meta_id, one on post_id and one on the first 191 characters of meta_key. There is no index on meta_value, and there cannot usefully be one: the column is LONGTEXT, and an index on a text column of unbounded length is either a prefix or nothing.

So every clause is answered in two movements. The join finds the rows for a post by post_id – fast, indexed, exactly what an index is for. Then the value is compared inside each of those rows, one at a time, with no help from anything. The work is proportional to how many meta rows a post has, which on a site with a page builder and a few plugins is rarely under thirty.

The Clauses That Cost More Than the Others

Clause What the database has to do
'compare' => '=' the baseline: one join, one string comparison per row
'compare' => 'LIKE' the same, with a pattern match; a leading wildcard rules out every shortcut
'type' => 'NUMERIC' wraps the column in CAST(), which removes any remaining chance of index use
'compare' => 'NOT EXISTS' a LEFT JOIN plus an IS NULL test – every non-matching row has to be produced first
'relation' => 'OR' the clauses can no longer narrow each other; the intermediate set is the union
'orderby' => 'meta_value' a temporary table and a filesort over a text column

The last row is the one that turns a slow page into a timeout. Sorting by a meta value cannot be done from an index, so the database materialises the whole intermediate result and sorts it – and because meta_value is LONGTEXT, that sort happens on disk rather than in memory as soon as the set is large.

NOT EXISTS deserves its own warning. It reads as the cheap opposite of EXISTS and is the opposite of cheap: proving that something is absent means visiting every candidate, which is the one thing an index cannot shorten.

Where the Filter Belongs Instead

A value that is filtered on is not a property of a post in the way a subtitle is. It is a category, and WordPress already has a structure for categories with the right indexes: wp_term_relationships is indexed on both of its columns, so a term filter is one join answered entirely from indexes, with no text comparison anywhere.

The rule that follows is short. Anything used for filtering or sorting belongs in a taxonomy – or, when the values are truly numeric and continuous, in a column of its own table. Anything only ever read after the post has been found can stay in post meta, where it costs a single lookup by post_id and nothing else.

Measuring Instead of Guessing

The query built by WP_Query is available before it runs: $query->request holds the finished SQL, and running it through EXPLAIN answers the only two questions that matter. The rows column is the estimate of how many rows each step will read; the Extra column names the expensive shapes outright, and Using temporary; Using filesort together are the signature of a sort that will not scale.

wp db query "EXPLAIN SELECT wp_posts.ID FROM wp_posts INNER JOIN ..."

id  select_type  table     type    key       rows    Extra
1   SIMPLE       wp_posts  ref     type_st.  4800    Using temporary; Using filesort
1   SIMPLE       pm1       ref     post_id     40    Using where
1   SIMPLE       pm2       ref     post_id     40    Using where
1   SIMPLE       pm3       ref     post_id     40    Using where

Three rows saying Using where after a post_id lookup is the picture described above: the index found the rows, and the condition was applied afterwards. The number in rows multiplied across the joins is the figure that grows with the site, and it is worth knowing on the day the query is written rather than on the day the page stops loading.

Lukas Wojcik

Lukas Wojcik

Systems architect and technology enthusiast specializing in scalable tracking solutions, GMP Stack (GA4 & GTM), and robust backend architectures. Advocate for clean code and privacy-first design.

Get in Touch

Briefly describe your project or inquiry for a tailored response. This site is protected by reCAPTCHA.

Write a comment

The email address is not published. Required fields are marked with an asterisk.

ALL ARTICLES & CATEGORIES

CCTV

Follow this category by RSS

Cloud & AI

Follow this category by RSS

Data Privacy

All 12 articles in this category Follow this category by RSS

Digital Analytics

All 46 articles in this category Follow this category by RSS

Digital Marketing

All 25 articles in this category Follow this category by RSS

IT & Networks

All 15 articles in this category Follow this category by RSS

Raspberry PI

Follow this category by RSS

Smart Home

All 13 articles in this category Follow this category by RSS

Web Development

Follow this category by RSS

WordPress Plugins & Tricks

Follow this category by RSS