Custom Database Tables in WordPress with dbDelta and wpdb

Sometimes the right solution is a custom database table — WordPress’s built-in post/meta structure is not ideal for high-volume transactional data like analytics events, audit logs, or form submissions. Creating and querying custom tables through $wpdb correctly requires knowing dbDelta(), prepared statements, and proper caching.

Problem: A WordPress plugin needs to store structured data that does not fit the post/taxonomy model — order line items, audit logs, or event registrations — but using wp_postmeta for this creates excessive rows and slow queries.

Solution: Create a dedicated database table in the plugin activation hook using dbDelta() — it handles CREATE and ALTER safely across installs and upgrades. Use $wpdb->prefix for the table name, $wpdb->prepare() for all queries, and store the schema version in wp_options to run migrations on update.

The examples below create a custom table with dbDelta() on plugin activation, perform safe CRUD operations with prepared statements, and cache query results with the WordPress object cache.

prefix . 'event_log';
    $charset = $wpdb->get_charset_collate();

    // dbDelta requires very specific SQL formatting — no extra newlines in column defs
    $sql = "CREATE TABLE {$table} (
  id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  user_id bigint(20) unsigned NOT NULL DEFAULT 0,
  event_type varchar(64) NOT NULL,
  object_id bigint(20) unsigned NOT NULL DEFAULT 0,
  meta longtext DEFAULT NULL,
  created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  KEY user_id (user_id),
  KEY event_type (event_type),
  KEY created_at (created_at)
) {$charset};";

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta( $sql );   // safe to run multiple times — only alters if schema changed

    // Store the schema version to know when to run dbDelta again
    update_option( 'myplugin_db_version', '1.0' );
}
register_activation_hook( __FILE__, 'myplugin_create_tables' );

// Run dbDelta on upgrade if schema version changed
add_action( 'plugins_loaded', function() {
    if ( get_option( 'myplugin_db_version' ) !== '1.0' ) {
        myplugin_create_tables();
    }
} );

Perform safe CRUD with prepared statements and object cache:

prefix . 'event_log';

    $result = $wpdb->insert( $table, [
        'user_id'    => $user_id,
        'event_type' => $event_type,
        'object_id'  => $object_id,
        'meta'       => wp_json_encode( $meta ),
    ], [ '%d', '%s', '%d', '%s' ] );

    return $result ? $wpdb->insert_id : false;
}

function myplugin_get_user_events( int $user_id, int $limit = 20 ): array {
    global $wpdb;
    $cache_key = "user_events_{$user_id}_{$limit}";
    $cached    = wp_cache_get( $cache_key, 'myplugin' );
    if ( false !== $cached ) return $cached;

    $table  = $wpdb->prefix . 'event_log';
    // Always use prepare() — never interpolate user data directly
    $rows   = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT id, event_type, object_id, created_at FROM {$table}
             WHERE user_id = %d ORDER BY created_at DESC LIMIT %d",
            $user_id, $limit
        ),
        ARRAY_A
    );

    wp_cache_set( $cache_key, $rows, 'myplugin', 300 );
    return $rows ?: [];
}

// Delete with prepare
function myplugin_purge_old_events( int $days = 90 ): int {
    global $wpdb;
    $table = $wpdb->prefix . 'event_log';
    return (int) $wpdb->query(
        $wpdb->prepare(
            "DELETE FROM {$table} WHERE created_at < DATE_SUB(NOW(), INTERVAL %d DAY)",
            $days
        )
    );
}

NOTE: Always prefix table names with $wpdb->prefix rather than hardcoding wp_ — multisite installs use different prefixes per blog. And never use $wpdb->query() with unsanitized input; $wpdb->prepare() is mandatory for any value that comes from user input or external data.

Leave Comment

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