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.