When to use a custom database table instead of post meta

Had a client come in last week. Their WooCommerce dashboard was taking ages to load, sometimes timing out completely. They run 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, and it was starting to drag the whole site down.

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. Start treating it like one and you’re going to have a bad time. The queries get slow and inefficient, especially when you need to retrieve specific data across thousands of users. This is a mistake I see all the time: developers force everything into the standard WordPress tables instead of creating proper custom database tables.

My first thought was, ‘Can I salvage this?’ Maybe write a more optimized WP_Query, or add a caching layer with transients. And yeah, that might have worked for a little while. But it’s just kicking the can down the road, trust me. The core problem wasn’t the query, it was the whole data architecture. Once you’re joining across a meta table with millions of rows, the performance fight is already over.

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 full 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. It’s an idea the folks at carlalexander.ca have been writing about for years.

We hooked this into the plugin activation hook using register_activation_hook. Now the data lives in its own indexed 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 much faster now because they hit a table built for exactly that data, with no messy meta joins to wade through.

So, what’s the lesson here?

The point is that WordPress gives you great tools like post meta and options, but they aren’t a fix for everything. Using the right tool for the job is the difference between a site that runs and a site that runs at scale. Spending an extra hour to build a custom table up front can save you and your client days of headaches and performance problems later on.

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.

author avatar
Ahmad Wael
I'm a WordPress and WooCommerce developer with 15+ years of experience building custom e-commerce solutions and plugins. I specialize in PHP development, following WordPress coding standards to deliver clean, maintainable code. Currently, I'm exploring AI and e-commerce by building multi-agent systems and SaaS products that integrate technologies like Google Gemini API with WordPress platforms, approaching every project with a commitment to performance, security, and exceptional user experience.