WordPress provides a robust database abstraction layer through its global $wpdb
object. Using this API correctly is essential for building performant, secure, and maintainable plugins and themes. This guide covers best practices for handling database queries in WordPress projects.
Note: For basic WordPress database API usage, refer to the official WordPress documentation on the $wpdb class.
Prepared statements protect against SQL injection by separating SQL code from data. Always use prepared statements when incorporating any variable data in your queries.
$wpdb->prepare()
global $wpdb;
$user_id = $_GET['user_id']; // Potentially unsafe user input
// WRONG - SQL injection vulnerability:
$results = $wpdb->get_results( "SELECT * FROM $wpdb->users WHERE ID = $user_id" );
// RIGHT - Using prepared statements:
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM $wpdb->users WHERE ID = %d",
$user_id
)
);
WordPress supports these placeholders:
%s
- String (most common, can be used for most data types)%d
- Integer%f
- Float%i
- SQL Identifier (for table/column names)%%
- Literal percentage character$wpdb->prepare(
"SELECT * FROM $wpdb->posts WHERE post_type = %s AND post_status = %s AND ID > %d",
$post_type,
$post_status,
$min_id
);
When dealing with dynamic table or column names, you have two options:
Since WordPress 6.1, the %i
placeholder can be used specifically for SQL identifiers (table and column names):
// Using %i for SQL identifiers
$column = 'post_title';
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT %i FROM $wpdb->posts",
$column
)
);
// Results in: SELECT `post_title` FROM wp_posts (valid SQL)
The %i
placeholder:
For broader compatibility or more complex scenarios:
// Sanitize and use directly with backticks
$column = sanitize_key( $column );
$results = $wpdb->get_results(
"SELECT `{$column}` FROM $wpdb->posts WHERE post_status = 'publish'"
);
Using %i
for identifiers:
// Using %i for column identifiers
$dimension = 'page';
$metrics = $wpdb->get_results(
$wpdb->prepare(
"SELECT t1.%i as %i, t1.clicks, t1.impressions
FROM ( SELECT %i, SUM(clicks) as clicks FROM {$wpdb->prefix}stats
GROUP BY %i) as t1
LEFT JOIN ( SELECT %i, SUM(clicks) as clicks FROM {$wpdb->prefix}stats
GROUP BY %i) as t2
ON t1.%i = t2.%i",
$dimension, $dimension, $dimension, $dimension,
$dimension, $dimension, $dimension, $dimension
)
);
// Results in valid SQL with properly escaped identifiers
While caching database results often improves performance, it’s not always necessary or beneficial.
Caching should be skipped in these scenarios:
/**
* Get current user's latest notification
* - No caching needed: User-specific and requires real-time data
*/
function get_user_latest_notification( $user_id ) {
global $wpdb;
return $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}notifications
WHERE user_id = %d
ORDER BY created_at DESC
LIMIT 1",
$user_id
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- User-specific real-time data, caching not appropriate
}
WordPress Coding Standards include rules about database queries that may trigger PHPCS warnings.
PHPCS often flags direct database calls with warnings like:
Direct database call detected
This is because WordPress often has built-in functions that should be used instead of direct DB queries.
If you’ve determined a direct DB call is necessary, use the PHPCS ignore annotation:
// For a single line
$results = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
// For a block of code
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
$results1 = $wpdb->get_results( $query1 );
$results2 = $wpdb->get_results( $query2 );
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
A better practice is to explain why you’re bypassing the rule:
$results = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$wpdb->prefix}custom_table WHERE user_id = %d", $user_id )
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table not accessible via WP functions
PHPCS may also flag uncached database calls:
Direct database call without caching detected
Use the following annotation when caching isn’t necessary, always including a clear justification:
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching not appropriate
$current_value = $wpdb->get_var(
$wpdb->prepare(
"SELECT meta_value FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = %s",
$post_id,
$meta_key
)
);
Common justifications for skipping caching:
Batching database operations is beneficial in these scenarios:
Bulk Data Processing: When you need to insert, update, or delete multiple rows at once (e.g., importing data, bulk user operations, migration scripts)
Atomic Operations: When multiple related changes need to succeed or fail together (e.g., transferring credits between accounts, complex inventory adjustments)
Performance-Critical Operations: When reducing the number of database round-trips is essential for performance (e.g., high-traffic areas of your application)
Background Processing: When processing large datasets through cron jobs or background tasks where efficiency matters more than immediate feedback
API Endpoints: When an API call might trigger multiple database changes that should be treated as a single unit of work
global $wpdb;
// Start a transaction
$wpdb->query( 'START TRANSACTION' );
try {
// Operation 1
$wpdb->update(
$wpdb->prefix . 'user_balance',
array( 'balance' => $new_sender_balance ),
array( 'user_id' => $sender_id ),
array( '%f' ),
array( '%d' )
);
// Operation 2
$wpdb->update(
$wpdb->prefix . 'user_balance',
array( 'balance' => $new_receiver_balance ),
array( 'user_id' => $receiver_id ),
array( '%f' ),
array( '%d' )
);
// Operation 3
$wpdb->insert(
$wpdb->prefix . 'transaction_log',
array(
'sender_id' => $sender_id,
'receiver_id' => $receiver_id,
'amount' => $amount,
'timestamp' => current_time( 'mysql' ),
),
array( '%d', '%d', '%f', '%s' )
);
// Commit if all operations succeeded
$wpdb->query( 'COMMIT' );
return true;
} catch ( Exception $e ) {
// Rollback if any operation failed
$wpdb->query( 'ROLLBACK' );
error_log( 'Transaction failed: ' . $e->getMessage() );
return false;
}
For extremely large datasets, a single multi-value INSERT can be more efficient:
function batch_insert_records( $records ) {
global $wpdb;
$table = $wpdb->prefix . 'my_table';
// Abort if no records
if ( empty( $records ) ) {
return false;
}
// Start building the query
$query = "INSERT INTO $table (column1, column2, column3) VALUES ";
$placeholders = array();
$values = array();
// Build placeholders and flatten values
foreach ( $records as $record ) {
$placeholders[] = "(%s, %d, %f)";
$values[] = $record['column1'];
$values[] = $record['column2'];
$values[] = $record['column3'];
}
// Combine the query
$query .= implode( ', ', $placeholders );
// Execute with all values at once
return $wpdb->query(
$wpdb->prepare( $query, $values )
);
}
For queries on WordPress native objects such as posts, use WordPress functions when appropriate:
$posts = get_posts( array(
'post_type' => 'product',
'meta_query' => array(
array(
'key' => 'color',
'value' => 'blue',
'compare' => '=',
),
),
) );