| name | wordpress-database |
| description | This skill should be used when the user asks to "create a custom database table", "write a WP_Query", "use $wpdb", "run a meta query", "migrate a database schema", "optimize WordPress queries", "use dbDelta", "add an index", or mentions "custom table", "$wpdb", "WP_Query", "meta_query", "tax_query", "date_query", "dbDelta", "get_results", "prepare", "SAVEQUERIES", "Query Monitor", "object cache", "wp_cache", "database migration", "schema versioning". Provides WordPress database development expertise covering $wpdb, custom tables, schema migrations, WP_Query, meta/taxonomy/date queries, caching layers, and query debugging. |
| license | MIT |
| metadata | {"author":"Chris Kelley (hello@iwritecode.io)","version":"1.0.0"} |
WordPress Database Development
This skill covers WordPress database patterns including $wpdb usage, custom table creation, schema migrations, WP_Query advanced queries, meta/taxonomy/date queries, caching layers, and query debugging.
Critical Rules
- Always use
$wpdb->prepare() for any query containing user-supplied or variable data — no exceptions.
- Always use
$wpdb->prefix for table names — never hardcode wp_.
- Use WordPress APIs first —
WP_Query, meta API, and options API before reaching for raw SQL.
- Use
dbDelta() for table creation and schema changes — it handles CREATE TABLE idempotently.
- Version your schema — store a version number in
wp_options and compare on plugin load.
- Never run schema changes on every page load — gate behind version checks.
- Cache expensive queries — use transients or the object cache for repeated lookups.
$wpdb Core Methods
global $wpdb;
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}orders WHERE status = %s",
'completed'
)
);
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}orders WHERE id = %d",
$order_id
)
);
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT id FROM {$wpdb->prefix}orders WHERE user_id = %d",
$user_id
)
);
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, total FROM {$wpdb->prefix}orders WHERE status = %s ORDER BY created_at DESC LIMIT %d",
'pending',
50
)
);
$wpdb->get_row( $sql, OBJECT );
$wpdb->get_row( $sql, ARRAY_A );
$wpdb->get_row( $sql, ARRAY_N );
CRUD Methods
Prefer these for single-row operations — they handle escaping via format arrays:
$wpdb->insert(
$wpdb->prefix . 'orders',
array(
'user_id' => $user_id,
'total' => $total,
'status' => 'pending',
'created_at' => current_time( 'mysql' ),
),
array( '%d', '%f', '%s', '%s' )
);
$new_id = $wpdb->insert_id;
$wpdb->update(
$wpdb->prefix . 'orders',
array( 'status' => 'completed' ),
array( 'id' => $order_id ),
array( '%s' ),
array( '%d' )
);
$wpdb->delete(
$wpdb->prefix . 'orders',
array( 'id' => $order_id ),
array( '%d' )
);
$wpdb->replace(
$wpdb->prefix . 'orders',
array(
'id' => $order_id,
'status' => 'refunded',
),
array( '%d', '%s' )
);
Format Placeholders
| Placeholder | Type | Example |
|---|
%d | Integer | 42 |
%f | Float | 19.99 |
%s | String | 'pending' |
%i | Identifier (table/column name, WP 6.2+) | order_id |
Custom Table Creation
register_activation_hook( __FILE__, 'myplugin_create_tables' );
function myplugin_create_tables(): void {
global $wpdb;
$table_name = $wpdb->prefix . 'myplugin_orders';
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table_name} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
total decimal(10,2) NOT NULL DEFAULT 0.00,
status varchar(20) NOT NULL DEFAULT 'pending',
notes text NOT NULL DEFAULT '',
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY user_id_idx (user_id),
KEY status_idx (status),
KEY created_at_idx (created_at)
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
update_option( 'myplugin_db_version', '1.0.0' );
}
dbDelta() formatting rules — these are strict and will silently fail if violated:
| Rule | Correct | Wrong |
|---|
| Spacing after column name | id bigint(20) (two spaces) | id bigint(20) |
| PRIMARY KEY line | PRIMARY KEY (id) (two spaces) | PRIMARY KEY (id) |
| Index keyword | KEY name (col) | INDEX name (col) |
| Named keys | KEY status_idx (status) | KEY (status) |
| Statement | One CREATE TABLE per call | Multiple statements |
Schema Versioning & Migrations
add_action( 'plugins_loaded', 'myplugin_check_db_version' );
function myplugin_check_db_version(): void {
$installed_version = get_option( 'myplugin_db_version', '0' );
$current_version = '1.2.0';
if ( version_compare( $installed_version, $current_version, '<' ) ) {
myplugin_run_migrations( $installed_version );
update_option( 'myplugin_db_version', $current_version );
}
}
function myplugin_run_migrations( string $from_version ): void {
global $wpdb;
$table = $wpdb->prefix . 'myplugin_orders';
if ( version_compare( $from_version, '1.1.0', '<' ) ) {
$col_exists = $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s',
DB_NAME,
$table,
'currency'
)
);
if ( ! $col_exists ) {
$wpdb->query( "ALTER TABLE {$table} ADD COLUMN currency varchar(3) NOT NULL DEFAULT 'USD' AFTER total" );
}
}
if ( version_compare( $from_version, '1.2.0', '<' ) ) {
$index_exists = $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME = %s',
DB_NAME,
$table,
'user_status_idx'
)
);
if ( ! $index_exists ) {
$wpdb->query( "ALTER TABLE {$table} ADD INDEX user_status_idx (user_id, status)" );
}
}
myplugin_create_tables();
}
Uninstall Cleanup
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}myplugin_orders" );
delete_option( 'myplugin_db_version' );
$wpdb->query( "DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE '_myplugin\_%'" );
WP_Query
Basic Usage
$query = new WP_Query( array(
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 20,
'paged' => get_query_var( 'paged' ) ?: 1,
'orderby' => 'date',
'order' => 'DESC',
) );
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
the_title();
}
wp_reset_postdata();
}
Meta Queries
$query = new WP_Query( array(
'post_type' => 'product',
'meta_query' => array(
'relation' => 'AND',
'price_clause' => array(
'key' => '_price',
'value' => array( 10, 50 ),
'type' => 'DECIMAL(10,2)',
'compare' => 'BETWEEN',
),
array(
'key' => '_stock_status',
'value' => 'instock',
'compare' => '=',
),
),
'orderby' => 'price_clause',
'order' => 'ASC',
) );
compare operators: =, !=, >, >=, <, <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN, NOT BETWEEN, EXISTS, NOT EXISTS, REGEXP, NOT REGEXP.
type cast values: NUMERIC, DECIMAL(10,2), CHAR, DATE, DATETIME, TIME, SIGNED, UNSIGNED, BINARY.
Taxonomy Queries
$query = new WP_Query( array(
'post_type' => 'product',
'tax_query' => array(
'relation' => 'AND',
array(
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => array( 'electronics', 'gadgets' ),
'operator' => 'IN',
),
array(
'taxonomy' => 'product_tag',
'field' => 'term_id',
'terms' => array( 42 ),
'operator' => 'NOT IN',
),
),
) );
field options: term_id (default), name, slug, term_taxonomy_id.
operator options: IN, NOT IN, AND, EXISTS, NOT EXISTS.
Date Queries
$query = new WP_Query( array(
'post_type' => 'post',
'date_query' => array(
'relation' => 'AND',
array(
'after' => '2024-01-01',
'before' => array(
'year' => 2024,
'month' => 12,
'day' => 31,
),
'inclusive' => true,
),
array(
'hour' => 9,
'compare' => '>=',
),
),
) );
Performance Tips for WP_Query
$query = new WP_Query( array(
'post_type' => 'product',
'posts_per_page' => 100,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
) );
Meta API
update_post_meta( $post_id, '_myplugin_price', '29.99' );
$price = get_post_meta( $post_id, '_myplugin_price', true );
delete_post_meta( $post_id, '_myplugin_price' );
update_user_meta( $user_id, 'myplugin_preference', 'dark' );
$pref = get_user_meta( $user_id, 'myplugin_preference', true );
update_term_meta( $term_id, 'myplugin_color', '#ff0000' );
$color = get_term_meta( $term_id, 'myplugin_color', true );
Caching Layers
Transients (Persistent, Survives Page Loads)
function myplugin_get_top_products(): array {
$cache_key = 'myplugin_top_products';
$products = get_transient( $cache_key );
if ( false !== $products ) {
return $products;
}
global $wpdb;
$products = $wpdb->get_results(
"SELECT p.ID, p.post_title, pm.meta_value AS total_sales
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE p.post_type = 'product'
AND p.post_status = 'publish'
AND pm.meta_key = 'total_sales'
ORDER BY CAST(pm.meta_value AS UNSIGNED) DESC
LIMIT 10"
);
set_transient( $cache_key, $products, HOUR_IN_SECONDS );
return $products;
}
add_action( 'save_post_product', function (): void {
delete_transient( 'myplugin_top_products' );
} );
Object Cache (Per-Request Unless Persistent Cache Plugin Installed)
$result = wp_cache_get( 'my_data', 'myplugin' );
if ( false === $result ) {
$result = expensive_calculation();
wp_cache_set( 'my_data', $result, 'myplugin', 300 );
}
| Layer | Persists Across Requests | Requires Plugin | Best For |
|---|
wp_cache_* (no backend) | No | No | Deduplicating queries within a single request |
wp_cache_* (Redis/Memcached) | Yes | Yes | Frequently accessed data, low-latency reads |
| Transients | Yes (in wp_options) | No | API responses, computed aggregates |
| Transients (with object cache) | Yes (in cache backend) | Yes | Same as above, but avoids wp_options bloat |
Query Debugging
SAVEQUERIES
define( 'SAVEQUERIES', true );
global $wpdb;
echo '<pre>';
print_r( $wpdb->queries );
echo '</pre>';
echo 'Total queries: ' . count( $wpdb->queries );
$wpdb Error Checking
$wpdb->show_errors();
$wpdb->suppress_errors();
if ( '' !== $wpdb->last_error ) {
error_log( 'DB error: ' . $wpdb->last_error );
error_log( 'Query: ' . $wpdb->last_query );
}
$wpdb->num_rows;
$wpdb->rows_affected;
Query Monitor Plugin
Recommended for development. Provides admin toolbar panel showing:
- All database queries with timing and caller
- Duplicate queries
- Slow queries
- HTTP API calls, hooks, and conditionals
For advanced query patterns and indexing strategies, see references/advanced-queries.md. For the full custom tables reference, see references/custom-tables.md.