Go back postmeta vs. custom tables: making the call before it’s too late /* by Tushar Sharma - July 5, 2026 */ Tech Update 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 tableFilter by 1 fieldSingle join, acceptableIndexed column, fastFilter by 3+ fields combinedMultiple self-joins, slow at scaleComposite index, fastNumeric range queriesValues stored as strings, cast on every queryNative numeric column typesSorting 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 1Create the new table via dbDeltaRun on plugin activation/update, additive only — no destructive changes yet. 2Dual-write: keep writing to postmeta and the new tableNew code path writes both; reads still come from postmeta. Zero behavior change yet. 3Backfill historical rowsBatch job (WP-CLI command, not a page-load hook) copies existing postmeta rows into the new table. 4Switch reads to the new tableFeature-flag this if possible, so it can be reverted instantly if something’s wrong. 5Stop dual-writing, deprecate the postmeta keysOnly 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 <?phpfunction 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 <?phpWP_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 <?phpclass 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 tablewp option update my_plugin_use_custom_table 1# Something wrong? Roll back instantly — no code deploy neededwp 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 tableNumber of postsUnder ~10,00010,000+ and growingFilter complexitySingle meta field, occasional use2+ fields combined, frequent front-end filtersData typesStrings and flagsNumerics, decimals, ranges, sortingAdmin list screen performanceFastVisibly slow — slow query log confirms itpostmeta table total row countUnder ~500k rowsMillions of rows, multiple plugins writingRelationships 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_queryEXPLAINSELECT p.IDFROM wp_posts pINNER 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 typeWhyPrice / amountVARCHARDECIMAL(12,2)Enables numeric BETWEEN and range comparisons without castingInteger counts (bedrooms, quantity)VARCHARSMALLINT UNSIGNED4 bytes vs 20+, index-friendly, no string-to-int cast on every rowShort text (city, state code)TEXTVARCHAR(100)TEXT columns cannot be fully indexed — VARCHAR canLat/lng coordinatesVARCHARDECIMAL(10,7)7 decimal places = ~1cm precision; enables numeric distance mathTimestampsVARCHARDATETIME or INT UNSIGNED (Unix)Range queries, sorting, and date functions all require proper types includes/create-table.php A properly typed, properly indexed schema <?phpdbDelta("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.