Discuss your project

postmeta vs. custom tables: making the call before it’s too late

/* by - July 5, 2026 */
postmeta vs. custom tables: making the call before it’s too late

1. How postmeta query performance degrades

wp_postmeta is an entity-attribute-value table: every meta key/value pair is its own row. A meta_query joining on three or four keys generates a multi-self-join query that doesn’t scale the way a properly indexed dedicated column would. The symptom is always the same — admin list screens and front-end filters that were fast at 1,000 posts and crawl at 100,000.

Query cost comparison

Patternpostmeta meta_queryDedicated custom table
Filter by 1 fieldSingle join, acceptableIndexed column, fast
Filter by 3+ fields combinedMultiple self-joins, slow at scaleComposite index, fast
Numeric range queriesValues stored as strings, cast on every queryNative numeric column types
Sorting by a meta fieldJoin + sort, no native index benefitIndexed column sort

This is what the problem actually looks like in code. A property listing filtered by price range, bedroom count, and city — three meta_query clauses — forces three self-joins against wp_postmeta, each comparing against an untyped longtext column:

the-slow-way.php WP_Query meta_query
<?php
$query = new WP_Query([
'post_type' => 'listing',
'posts_per_page' => 20,
'meta_query' => [
'relation' => 'AND',
[
'key' => 'price',
'value' => [200000, 450000],
'type' => 'NUMERIC',
'compare' => 'BETWEEN',
],
[
'key' => 'bedrooms',
'value' => 3,
'type' => 'NUMERIC',
'compare' => '>=',
],
[
'key' => 'city',
'value' => 'Austin',
'compare' => '=',
],
],
]); ?>

Three clauses, three self-joins on the same table — and price/bedrooms are cast from text to numeric on every row checked, since postmeta has no real numeric column type. Here’s the equivalent against a dedicated table, with composite indexes doing the work instead of repeated joins:

the-fast-way.phpDirect $wpdb query, indexed columns
<?php global $wpdb;
$ids = $wpdb->get_col($wpdb->prepare("
SELECT post_id
FROM {$wpdb->prefix}listing_details
WHERE city = %s
AND bedrooms >= %d
AND price BETWEEN %d AND %d
ORDER BY price ASC
LIMIT 20
", 'Austin', 3, 200000, 450000)); ?>

One table, one pass, real numeric comparisons, and a composite index on (city, bedrooms, price) can satisfy the whole WHERE clause without a separate sort step. The query plan stops growing with the total size of every plugin’s postmeta and starts scaling with just this table.

2. Migrating without taking the site down

1
Create the new table via dbDelta

Run on plugin activation/update, additive only — no destructive changes yet.

2
Dual-write: keep writing to postmeta and the new table

New code path writes both; reads still come from postmeta. Zero behavior change yet.

3
Backfill historical rows

Batch job (WP-CLI command, not a page-load hook) copies existing postmeta rows into the new table.

4
Switch reads to the new table

Feature-flag this if possible, so it can be reverted instantly if something’s wrong.

5
Stop dual-writing, deprecate the postmeta keys

Only after a full release cycle of confidence in the new read path.

Step 1 in code — an additive dbDelta call that’s safe to run on every plugin update, since dbDelta only creates or alters, never drops:

includes/create-table.php Step 1 — dbDelta
<?php
function my_plugin_create_listing_details_table(): void
{
global $wpdb;
$table = $wpdb->prefix . 'listing_details';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
post_id BIGINT UNSIGNED NOT NULL,
city VARCHAR(100) NULL,
bedrooms SMALLINT UNSIGNED NULL,
price DECIMAL(12,2) NULL,
PRIMARY KEY (post_id),
KEY city_bedrooms_price (city, bedrooms, price)
) {$charset}";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
}
add_action('plugins_loaded', 'my_plugin_create_listing_details_table'); ?>

Step 2 — the dual-write. The existing postmeta save path is left completely untouched; a new hook just mirrors the same values into the new table alongside it:

includes/dual-write.phpStep 2 — write to both
<?php
function my_plugin_sync_listing_details(int $post_id): void
{
if (get_post_type($post_id) !== 'listing') {
return;
}
global $wpdb;
// Existing postmeta fields are still written exactly as before —
// this just mirrors the same values into the new table.
$wpdb->replace(
$wpdb->prefix . 'listing_details',
[
'post_id' => $post_id,
'city' => get_post_meta($post_id, 'city', true),
'bedrooms' => (int) get_post_meta($post_id, 'bedrooms', true),
'price' => (float) get_post_meta($post_id, 'price', true),
],
['%d', '%s', '%d', '%f']
);
}
add_action('save_post_listing', 'my_plugin_sync_listing_details', 20); ?>

Why $wpdb->replace() and not insert(): replace performs an upsert keyed on the primary key — it inserts the row on first save and overwrites it on every edit after, with no separate “does this row already exist” check needed.

Step 3, the backfill, is the same pattern as step 2 run once over every existing post instead of on save — batched through WP-CLI so it can’t time out or lock the table for an extended stretch:

wp-cli/backfill-listing-details.php Step 3 — batched WP-CLI backfill

<?php
// Register as a WP-CLI command: wp listings backfill.
WP_CLI::add_command('listings backfill', function() {
    $paged     = 1;
    $migrated  = 0;
    $skipped   = 0;

    do {
        $ids = get_posts([
            'post_type'      => 'listing',
            'post_status'    => 'any',
            'posts_per_page' => 200,
            'paged'          => $paged,
            'fields'         => 'ids',
        ]);

        foreach ($ids as $id) {
            $city = get_post_meta($id, 'city', true);

            // Skip posts with no location data — don't write empty rows
            if (empty($city)) { $skipped++; continue; }

            my_plugin_sync_listing_details($id);
            $migrated++;
        }

        WP_CLI::log("Batch {$paged}: {$migrated} migrated, {$skipped} skipped");
        $paged++;

        // Free memory between batches — essential on large tables
        global $wpdb;
        $wpdb->flush();

    } while (!empty($ids));

    WP_CLI::success("Done. {$migrated} rows migrated, {$skipped} skipped.");
}); ?>

Before touching reads, validate that the backfill is actually complete. This WP-CLI command compares row counts between postmeta and the new table so you have a concrete number to trust before flipping anything:

wp-cli/validate-migration.php Step 3b — validate before switching reads
<?php
WP_CLI::add_command('listings validate', function() {
global $wpdb;
// How many listings have a 'city' postmeta entry?
$meta_count = (int) $wpdb->get_var("
SELECT COUNT(DISTINCT post_id)
FROM {$wpdb->postmeta}
WHERE meta_key = 'city'
AND meta_value != ''
");
// How many rows are in the new table?
$table_count = (int) $wpdb->get_var("
SELECT COUNT(*) FROM {$wpdb->prefix}listing_details
");
WP_CLI::log("postmeta entries : {$meta_count}");
WP_CLI::log("custom table rows: {$table_count}");
if ($meta_count === $table_count) {
WP_CLI::success("Counts match. Safe to switch reads.");
} else {
$diff = $meta_count - $table_count;
WP_CLI::warning("Mismatch: {$diff} rows still need migrating. Re-run backfill.");
}
}); ?>

Step 4 — switching reads to the new table behind a feature flag. The flag is a simple wp_options boolean so the switch is reversible in seconds without a code deploy:

src/ListingQuery.php Step 4 — feature-flagged read switch
<?php
class Listing_Query
{
/**
* Returns filtered listing IDs.
* Reads from the custom table when the migration flag is on,
* falls back to meta_query when it's off.
*/
public function getIds(string $city, int $minBeds, float $minPrice, float $maxPrice): array
{
if (get_option('my_plugin_use_custom_table', false)) {
return $this->queryCustomTable($city, $minBeds, $minPrice, $maxPrice);
}
return $this->queryPostmeta($city, $minBeds, $minPrice, $maxPrice);
}
private function queryCustomTable(string $city, int $beds, float $min, float $max): array
{
global $wpdb;
return $wpdb->get_col($wpdb->prepare("
SELECT l.post_id
FROM {$wpdb->prefix}listing_details l
INNER JOIN {$wpdb->posts} p ON p.ID = l.post_id
WHERE l.city = %s
AND l.bedrooms >= %d
AND l.price BETWEEN %f AND %f
AND p.post_status = 'publish'
AND p.post_type = 'listing'
ORDER BY l.price ASC
", $city, $beds, $min, $max));
}
private function queryPostmeta(string $city, int $beds, float $min, float $max): array
{
$query = new WP_Query([
'post_type' => 'listing',
'fields' => 'ids',
'posts_per_page' => -1,
'meta_query' => [
'relation' => 'AND',
['key' => 'city', 'value' => $city],
['key' => 'bedrooms', 'value' => $beds, 'type' => 'NUMERIC', 'compare' => '>='],
['key' => 'price', 'value' => [$min, $max], 'type' => 'NUMERIC', 'compare' => 'BETWEEN'],
],
]);
return $query->posts;
}
} ?>

Enable the flag via WP-CLI to avoid touching the admin UI during a sensitive cutover:

terminal Flip the flag — enable and rollback

# Enable — reads now come from the custom table
wp option update my_plugin_use_custom_table 1
# Something wrong? Roll back instantly — no code deploy needed
wp option update my_plugin_use_custom_table 0

Step 5 — once reads have been on the new table for a full release cycle with no issues, stop the dual-write and delete the postmeta migration keys. Don’t rush this step: the dual-write is cheap, and the ability to roll back is worth keeping for longer than feels necessary.

includes/cleanup.phpStep 5 — stop dual-writing, clean up postmeta
<?php
// Remove the dual-write hook — add this once you're confident
// the custom table is the sole source of truth.
remove_action('save_post_listing', 'my_plugin_sync_listing_details', 20);
// Optionally clean up the now-redundant postmeta rows in a
// batched WP-CLI command — never in a page-load hook.
WP_CLI::add_command('listings cleanup-postmeta', function() {
global $wpdb;
$keys = ['city', 'bedrooms', 'price'];
foreach ($keys as $key) {
$deleted = $wpdb->delete(
$wpdb->postmeta,
['meta_key' => $key],
['%s']
);
WP_CLI::log("Deleted {$deleted} postmeta rows for key '{$key}'");
}
WP_CLI::success("Postmeta cleanup complete.");
});
?>

Never bulk-delete postmeta in a migration hook or activation callback. A DELETE against hundreds of thousands of rows needs to run as a deliberate, logged, batched CLI command — not something that fires automatically and takes down the site mid-request.

3. Deciding when to make the switch

Not every postmeta usage warrants a custom table. The migration adds real complexity — a new schema to maintain, a migration pipeline to run, and a validation step before you can trust your own data. That cost only makes sense when the gains are proportionate.


Decision criteria
ScenarioStay with postmetaMove to custom table
Number of postsUnder ~10,00010,000+ and growing
Filter complexitySingle meta field, occasional use2+ fields combined, frequent front-end filters
Data typesStrings and flagsNumerics, decimals, ranges, sorting
Admin list screen performanceFastVisibly slow — slow query log confirms it
postmeta table total row countUnder ~500k rowsMillions of rows, multiple plugins writing
Relationships to other dataIsolated per-post valuesFields that need JOIN to other custom tables

The fastest check before committing to a migration: open MySQL’s slow query log, run your filter on a production or staging copy, and look at the EXPLAIN output. If you see type: ALL or a large rows estimate against wp_postmeta, the migration will help. If the query is already hitting an index efficiently, it probably won’t.

mysql Run EXPLAIN before deciding anything

-- Check what MySQL is actually doing with your meta_query
EXPLAIN
SELECT p.ID
FROM wp_posts p
INNER JOIN wp_postmeta city_meta
ON city_meta.post_id = p.ID
AND city_meta.meta_key = 'city'
AND city_meta.meta_value = 'Austin'
INNER JOIN wp_postmeta beds_meta
ON beds_meta.post_id = p.ID
AND beds_meta.meta_key = 'bedrooms'
AND beds_meta.meta_value >= '3'
INNER JOIN wp_postmeta price_meta
ON price_meta.post_id = p.ID
AND price_meta.meta_key = 'price'
AND price_meta.meta_value BETWEEN '200000' AND '450000'
WHERE p.post_type = 'listing'
AND p.post_status = 'publish';
-- Red flags in the output:
-- type: ALL or type: ref with large 'rows' estimate
-- Extra: Using filesort, Using temporary
-- key: NULL on any of the postmeta joins

4. Schema design tips for the custom table

The table schema is where the real performance gains live or get wasted. A custom table with the wrong column types or missing indexes can be slower than postmeta. A few rules that matter more than most:

Column type decisions and why they matter
DATAWrong typeRight typeWhy
Price / amountVARCHARDECIMAL(12,2)Enables numeric BETWEEN and range comparisons without casting
Integer counts (bedrooms, quantity)VARCHARSMALLINT UNSIGNED4 bytes vs 20+, index-friendly, no string-to-int cast on every row
Short text (city, state code)TEXTVARCHAR(100)TEXT columns cannot be fully indexed — VARCHAR can
Lat/lng coordinatesVARCHARDECIMAL(10,7)7 decimal places = ~1cm precision; enables numeric distance math
TimestampsVARCHARDATETIME or INT UNSIGNED (Unix)Range queries, sorting, and date functions all require proper types

includes/create-table.php A properly typed, properly indexed schema
<?php
dbDelta("CREATE TABLE {$table} (
post_id BIGINT UNSIGNED NOT NULL,
city VARCHAR(100) NULL,
state VARCHAR(100) NULL,
bedrooms SMALLINT UNSIGNED NULL,
price DECIMAL(12,2) NULL,
lat DECIMAL(10,7) NULL,
lng DECIMAL(10,7) NULL,
PRIMARY KEY (post_id),
-- Composite index for the most common filter combination
KEY city_beds_price (city, bedrooms, price),
-- State-level archive pages
KEY state_price (state, price),
-- Geo bounding-box pre-filter before distance calculation
KEY lat_lng (lat, lng)
) {$charset}");
?>

Index order in composite keys matters. KEY city_beds_price (city, bedrooms, price) is optimally ordered for a query that filters on city = ? and bedrooms >= ? and sorts by price. MySQL reads composite indexes left-to-right — the most selective column (usually the equality filter) should come first, range filters after, the sort column last.