The wp_postmeta table is the single biggest source of slow queries on most WordPress sites. Every call to get_post_meta() without the $single parameter or with a hot-path meta key can turn into a full table scan. The fixes — meta caching, update_post_meta_cache, composite queries, and the EAV-to-normalised migration pattern — together can cut database time by 60–90% on post-heavy pages.
Problem: A WooCommerce store with thousands of products generates hundreds of wp_postmeta rows per product — after years of operation, the table has millions of rows and even indexed meta queries take seconds.
Solution: Audit wp_postmeta for orphaned rows (meta with no matching post) using DELETE pm FROM wp_postmeta pm LEFT JOIN wp_posts p ON p.ID = pm.post_id WHERE p.ID IS NULL. Identify the largest meta keys with SELECT meta_key, COUNT(*) FROM wp_postmeta GROUP BY meta_key ORDER BY 2 DESC. Move high-volume structured meta to a dedicated custom table using dbDelta(), and ensure the (post_id, meta_key) compound index exists.
The code below demonstrates the update_postmeta_cache pre-loading pattern, how to detect N+1 meta queries with Query Monitor, a bulk meta fetch with a single SQL query, and a strategy for moving high-frequency meta to a dedicated table.
'ids', 'numberposts' => 20 ] );
// This single query loads all meta for all 20 posts into WordPress's object cache.
// Subsequent get_post_meta() calls return from cache — no additional queries.
update_postmeta_cache( $post_ids );
foreach ( $post_ids as $id ) {
$price = get_post_meta( $id, '_price', true ); // cache hit — no DB query
}
// ── 2. WP_Query: always set update_post_meta_cache ────────────────────────
$query = new WP_Query( [
'post_type' => 'product',
'posts_per_page' => 20,
'update_post_meta_cache' => true, // default true, but explicit is clearer
'update_post_term_cache' => true,
'no_found_rows' => true, // skip COUNT(*) when pagination not needed
] );
// ── 3. Single bulk query for a specific meta key across many posts ─────────
function get_bulk_meta( array $post_ids, string $meta_key ): array {
global $wpdb;
if ( empty( $post_ids ) ) {
return [];
}
$placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
$args = array_merge( $post_ids, [ $meta_key ] );
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, meta_value
FROM {$wpdb->postmeta}
WHERE post_id IN ($placeholders)
AND meta_key = %s",
...$args
)
);
return array_column( $rows, 'meta_value', 'post_id' );
}
$prices = get_bulk_meta( $post_ids, '_price' ); // 1 query instead of 20
// ── 4. Move hot meta to a dedicated table (high-scale pattern) ────────────
// Create: CREATE TABLE wp_product_stats (post_id BIGINT PRIMARY KEY, views INT, rating FLOAT);
// Read:
function get_product_stat( int $post_id ): ?object {
global $wpdb;
return $wpdb->get_row(
$wpdb->prepare(
"SELECT views, rating FROM {$wpdb->prefix}product_stats WHERE post_id = %d",
$post_id
)
);
}
// This allows INDEX on post_id (already PK) and avoids the EAV overhead entirely.
NOTE: The single most impactful change on most WordPress sites is ensuring update_post_meta_cache is true in every WP_Query call that iterates over posts — the default is true, but plugins often set it to false to "optimise" queries, which actually creates N+1 meta query explosions on archive and loop pages.