Stop Using Post Meta for Everything, Seriously.

Had a client come in last week. Their WooCommerce dashboard was taking ages to load—sometimes timing out completely. They have a fairly popular membership site, and a previous developer had built a custom plugin to track user badges. Nice idea, but the execution was… a mess. Every time a user earned a badge, it created a new row in wp_postmeta. After a year, they had millions of rows in that table. It was a ticking time bomb, and it was starting to go off.

Look, the wp_postmeta table is great for what it’s for: storing metadata about a post. A feature image ID, a custom field, whatever. But it was never designed to be an application database. When you start treating it like one, you’re going to have a bad time. The queries become incredibly slow and inefficient, especially when you need to retrieve specific data across thousands of users. This is a classic mistake I see all the time; developers try to force everything into the standard WordPress tables instead of creating proper custom database tables.

My first thought was, ‘Can I salvage this?’ Maybe I could write a more optimized WP_Query or add a caching layer with transients. And yeah, that might have worked… for a little while. But that’s just kicking the can down the road. Trust me on this. The core issue wasn’t the query; it was the entire data architecture. The moment you start joining across a meta table with millions of rows, you’ve already lost the performance battle.

When You Actually Need Custom Database Tables

The right way to handle this is to create a dedicated table for your data. It’s not that hard, and it gives you complete control over the structure and indexing. For this client, we needed a simple table: a user ID, a badge ID, and a timestamp. That’s it. We’re not storing complex objects, just relational data. A concept that folks like the one at carlalexander.ca have been discussing for years.

We hooked this into the plugin activation hook using register_activation_hook. Simple, clean, and now the data lives in its own indexed, optimized table.

function bbioon_install_user_badges_table() {
    global $wpdb;
    $table_name = $wpdb->prefix . 'bbioon_user_badges';
    $charset_collate = $wpdb->get_charset_collate();

    $sql = "CREATE TABLE $table_name (
      id mediumint(9) NOT NULL AUTO_INCREMENT,
      time datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
      user_id bigint(20) UNSIGNED NOT NULL,
      badge_id varchar(255) NOT NULL,
      PRIMARY KEY  (id),
      KEY user_id (user_id)
    ) $charset_collate;";

    require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
    dbDelta( $sql );
}
register_activation_hook( __FILE__, 'bbioon_install_user_badges_table' );

Queries are now lightning-fast because they’re hitting a table designed for that specific data. No more messy meta queries. Just clean, fast SQL.

So, What’s the Real Lesson Here?

The point is this: WordPress gives you amazing tools like post meta and options, but they aren’t a silver bullet. Using the right tool for the job is what separates a site that works from a site that works at scale. Taking an extra hour to build a custom table from the start can save you—and your client—days of headaches and performance nightmares down the line. Don’t build a technical debt bomb.

Look, this stuff gets complicated fast. If you’re tired of debugging someone else’s mess and just want your site to work, drop my team a line. We’ve probably seen it before.

Leave a Reply

Your email address will not be published. Required fields are marked *