Do not use post meta!
Post metas are a key value store to extend the posts data. They are easy to use and well integrated into WP_Query arguments. With classic editor it is not even necessary to provide custom ui because of the default post meta box, even though that would not be a great user experience. If you use Gutenberg it is also no big deal to add ui that saves its value in the post meta table. But in some cases it is really not recommendable to use post meta values. If you want to know why and what you should use instead, keep on reading.
Please keep in mind that most WordPress installations will not have any troubles using post metas. But high traffic websites that have a lot of contents and post meta values will probably suffer from performance issues.
How does post meta work?
Post meta is a key value store table that you can use to add some meta information to the posts. It’s a database table that holds the reference to the post, a meta key and a meta value.
// save post meta value
add_post_meta($post_id, "reading_time", "5");
// get post meta value
$readingTime = get_post_meta($post_id, "reading_time", true);
Pretty simple, right? Now let’s get all posts that have a reading time of 5 minutes or more.
$query = new WP_Query([
"meta_query" => [
[
"key" => "reading_time",
"compare" => ">=",
"value" => "5",
"type" => "NUMERIC",
]
]
]);
This will work. It will work very well until you have a system with several tens of thousands of posts and a heavy use of post meta values.
The problem is the data type of the value column. It’s a TEXT column which means it can potentially save large strings. That’s reasonable because you may want to store all kinds of values and the data type TEXT offers the greatest flexibility. But it is also very inefficient and can be slow in queries.
Depending on your server resources at some point the computation of the query cannot be done in ram and has to be moved to the disk. This is where things get slow.
A better approach
The problem with the post meta table is that its data structure is not optimized for our use case. So let’s create a table that is optimized for our data.
Custom database table
Our table will have two columns. A reference to the post and a reading time column. Every column has the data type that fits its use case and indexes are present.
Now we can use this database class to set and get a posts reading time as simple or even simpler as with post meta values.
$db = new Database();
$db->set($post_id, 5);
$readingTime = $db->get($post_id);
Custom WP_Query argument
Of course we also want to provide an easy WP_Query integration. So we have to extend the WP_Query arguments to provide the same possibilities as post metas do. It should look something like this to query for post by reading time.
$query = new WP_Query([
"reading_time" => [
"compare" => ">=",
"value" => 5,
],
]);
We will use the posts_where
filter to add some more conditions to the sql query that is generated by the WP_Query class.
First we select all post ids that match the reading time condition from our custom table. We use this subquery to match only posts that have ids within this set of post ids. Our subquery is fully indexed and lightning fast. The post ID is the primary key of the posts table so it is indexed too. All added conditions should use indexes which results in fast query results.
Performance comparison
Let’s compare the two SQL queries that are build by the WP_Query class with meta_query argument and with reading_time argument and check how they perform.
Post Meta Query
$query = new WP_Query([
"meta_query" => [
[
"key" => "reading_time",
"compare" => ">=",
"value" => "5",
"type" => "NUMERIC",
]
]
]);
Results in:
SELECT posts.ID FROM posts
INNER JOIN postmeta ON ( posts.ID = postmeta.post_id )
WHERE 1=1
AND (
(
postmeta.meta_key = 'reading_time'
AND
CAST( postmeta.meta_value AS SIGNED) >= '5'
)
)
AND posts.post_type = 'post'
AND (posts.post_status = 'publish' OR posts.post_status = 'private')
GROUP BY posts.ID
ORDER BY posts.post_date DESC
LIMIT 0, 10;
Custom Reading Time Query
$query = new WP_Query([
"reading_time" => [
"compare" => ">=",
"value" => 5,
],
]);
Results in:
SELECT posts.ID FROM posts
WHERE 1=1
AND posts.post_type = 'post'
AND (posts.post_status = 'publish' OR posts.post_status = 'private')
AND mon_posts.ID IN (
SELECT post_id FROM mon_posts_reading_time
WHERE reading_time >= 5
)
ORDER BY posts.post_date DESC
LIMIT 0, 10;
Tests
I’ve run these queries with SELECT SQL_NO_CACHE ...
on a MacBook 2019 32GB RAM with a clients WordPress instance that has 408.101 rows in posts table, 178.104 are publish or private and 660.165 postmeta rows, 175.388 of these are reading_time post meta rows. Exactly the same number and combination of reading time and post id values are in the custom reading time table.
| Post Meta Query | Custom Table Query |
|-----------------|--------------------|
| 1.1s | 119ms |
| 1.224s | 120ms |
| 1.227s | 122ms |
| 1.232s | 121ms |
| 1.238s | 119ms |
Conclusion
Of course you can use post meta values. But if you want to use those values as query arguments think about twice if it would be better to use a custom table.