Post Meta vs a Custom Table: Where Filterable WordPress Data Should Live
Post meta, taxonomies, and custom tables each solve a different filtering problem. Compare them, then verify the choice with EXPLAIN and SAVEQUERIES.
14 Mar 2026, 17:22 UTC

The decision in front of you
You are storing structured records attached to posts — prices, coordinates, availability windows — and you need to filter, sort, or aggregate on them. WordPress offers three supported places to put that data: post meta queried with meta_query, a custom taxonomy queried with tax_query, or a table you create yourself and query through $wpdb. The choice is mostly about row count and how many attributes you filter on, not about which API feels nicer.
Working rule: if the value is displayed alongside its post and rarely used as a filter, post meta is the lower-effort path. If you filter on two or more numeric or date attributes across tens of thousands of rows, a custom table wins. Taxonomies sit in between and only help when values are shared across many records.
What each option does at the database level
Post meta lives in wp_postmeta as key/value rows. The meta_value column is a longtext, so a numeric comparison in meta_query becomes a string comparison unless you pass 'type' => 'NUMERIC', which adds a CAST(). Either way the value column has no range index, and each additional meta condition adds another join against the same table. The indexes that exist cover post_id and a prefix of meta_key.
A custom table gives you real column types and a composite index you design. You lose the automatic wiring: no revisions, no REST exposure, no admin list-table column, no automatic cache invalidation tied to post saves.
A custom taxonomy is optimized for grouping and filtering on shared terms. It is a poor fit for unique per-record values — a price is not a term.
Compact comparison
| Option | Best for | Query cost at scale | Free integration | Maintenance |
|---|---|---|---|---|
Post meta + meta_query | Per-post attributes shown with the post; light filtering | One join per condition; value column unindexed, CAST for numeric | REST via register_post_meta, revisions, admin UI, object cache | Lowest |
Custom taxonomy + tax_query | Shared categorical filters, archives, term counts | Good; term relationships are indexed | REST, admin UI, term counts | Low |
Custom table + $wpdb | Multi-attribute filtering, sorting, aggregation over large sets | Best when the index matches the query shape | None — you build REST routes, admin screens, caching | Highest: migrations, sanitization, capability checks |
Trade-offs worth naming
Object caching helps repeated meta lookups but does not make an unindexable filter fast. A persistent object cache drop-in reduces round trips; it does not change the query plan. Do not use it to justify a schema that cannot be indexed.
The hybrid is common and defensible: canonical rows in a custom table, plus a small set of meta values synced for admin display or REST. Accept the duplication explicitly — decide which side is authoritative and write the sync in exactly one place.
Concrete implementation: create the table and verify it applied
Run this from a plugin activation hook. The database user needs CREATE on the site's schema, which is normally true for the WordPress user on managed hosts.
register_activation_hook( __FILE__, 'myplugin_install_table' );
function myplugin_install_table() {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$table = $wpdb->prefix . 'listing_metrics';
$collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL,
price_cents int(10) unsigned NOT NULL DEFAULT 0,
latitude decimal(9,6) NOT NULL DEFAULT 0,
updated_at datetime NOT NULL,
PRIMARY KEY (id),
KEY post_id (post_id),
KEY price_lat (price_cents, latitude)
) {$collate};";
dbDelta( $sql );
}
dbDelta() is formatting-sensitive: one column per line, two spaces after PRIMARY KEY, and KEY rather than INDEX. When the statement does not match its expectations, a change can fail to apply without a visible error — which is why the next step is not optional.
Verify the structure after activation. From the WordPress root with WP-CLI (it runs the statement as the WordPress database user), or any database client:
wp db query "SHOW COLUMNS FROM wp_listing_metrics;"
wp db query "SHOW INDEX FROM wp_listing_metrics;"
Replace wp_ with your actual $wpdb->prefix. Confirm the columns, their types, and that the composite key exists. If a column is missing, fix the SQL formatting and re-run activation.
Validating the choice with real rows
- Insert synthetic rows at a realistic count — a few thousand at minimum, more if you expect six figures. A WP-CLI command or an admin-only script keeps this repeatable after index changes.
- Run both queries. For the meta version, use
WP_Querywithmeta_queryand'type' => 'NUMERIC'; for the table version, a$wpdb->prepare()SELECT. - Inspect the generated SQL. Set
SAVEQUERIESto true inwp-config.phpon a development copy, or use a query-monitor plugin, and read whatWP_Queryactually emitted. Watch for duplicate joins when the same key appears in bothmeta_keyandmeta_query. - Compare plans. Run
EXPLAINon both statements and look at rows examined and whether the composite index is used.
Do not treat timings from a laptop as production numbers. The useful signal is the shape: index used or not, rows examined, joins per condition.
Safety, rollback, and limits
Every interpolated value in a custom-table query must go through $wpdb->prepare(). Custom tables bypass the sanitization that post APIs give you, and they are frequently missed by backup and migration tooling — confirm your backup plugin includes them before you commit.
Rollback is a schema change, so treat it as one: dropping the table on uninstall destroys data. Prefer a deactivation that leaves the table in place plus a documented manual drop, and export the table before any schema migration.
Finally, re-test this decision against the WordPress versions you actually support. REST integration details and query internals have changed across releases; the benchmark, not the assumption, is what tells you whether the choice still holds.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.