WordPress gives developers several APIs for working with posts, users, metadata, options, and other common data. When those APIs do not fit the requirement—such as a custom table, reporting query, aggregate, or complex join—the global $wpdb object provides direct access to the WordPress database.
That flexibility also creates responsibility. Queries must protect user input, work with customized table prefixes, enforce permissions, and handle failures without exposing database details. Test changes on staging before deploying them; this guide to creating a WordPress staging copy in cPanel can help you prepare a safe test environment.
What $wpdb Does in WordPress
$wpdb is the global instance of WordPress’s wpdb class. It connects application code to the MySQL or MariaDB database used by the site. WordPress core tables store posts, users, options, comments, and related data, while plugins may add custom tables for specialized features.
Use a standard WordPress API whenever it provides the functionality you need. For example, use get_posts() for ordinary post queries and the Settings API for plugin settings. Direct queries are appropriate when you need a custom table, a complex query, an aggregate result, or an operation that the standard APIs do not support efficiently.
Protect Values with Prepared Statements
Never concatenate request data directly into SQL. This pattern is unsafe:
$email = $_GET['email'];
$sql = "SELECT * FROM {$wpdb->prefix}customers WHERE email = '$email'";
$results = $wpdb->get_results($sql);
Use $wpdb->prepare() and placeholders instead:
global $wpdb;
$email = sanitize_email(wp_unslash($_GET['email'] ?? ''));
$table = $wpdb->prefix . 'customers';
$sql = $wpdb->prepare(
"SELECT * FROM {$table} WHERE email = %s",
$email
);
$results = $wpdb->get_results($sql);
Common placeholders include %s for strings, %d for integers, and %f for floating-point values. Sanitization and validation still matter, but they do not replace query preparation. Preparation protects the SQL structure; validation checks whether a value is suitable for the feature.
Placeholders are for values, not table names, column names, or SQL keywords. If a column must be selected dynamically, map approved options in code rather than accepting arbitrary SQL fragments from a request.
Use the Configured WordPress Table Prefix
Do not assume that every WordPress installation uses the wp_ prefix. Sites may use a different prefix for configuration, migration, or hosting reasons. Use $wpdb->prefix when constructing names for standard WordPress or plugin tables:
global $wpdb;
$table = $wpdb->prefix . 'customer_notes';
For known core tables, properties such as $wpdb->posts, $wpdb->users, and $wpdb->options are clearer and remain compatible with the configured prefix.
A custom plugin table should have a consistent suffix, such as customer_notes. Document its schema and create or update it during plugin activation with dbDelta(). Avoid creating tables during normal page requests.
CRUD Queries with $wpdb
Create: Insert a Row
global $wpdb;
$table = $wpdb->prefix . 'customer_notes';
$inserted = $wpdb->insert(
$table,
array(
'customer_id' => 42,
'note' => 'Requested a callback',
'created_at' => current_time('mysql'),
),
array('%d', '%s', '%s')
);
if (false === $inserted) {
error_log($wpdb->last_error);
}
The insert() helper is often easier to review than a manually assembled INSERT statement. The format array tells WordPress how to handle each value.
Read: Retrieve One or Many Rows
$note = $wpdb->get_row(
$wpdb->prepare(
"SELECT id, note, created_at FROM {$table} WHERE id = %d",
10
)
);
$notes = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, note FROM {$table} WHERE customer_id = %d ORDER BY id DESC",
42
)
);
Use get_var() for a single value, such as a count, and get_col() when you need one column from several rows. Handle both missing records and empty result sets before displaying data.
Update: Change Existing Data
$updated = $wpdb->update(
$table,
array('note' => 'Callback completed'),
array('id' => 10),
array('%s'),
array('%d')
);
if (false === $updated) {
error_log($wpdb->last_error);
}
A return value of zero does not necessarily indicate failure. It can mean that the row exists but already contains the requested values. Treat false as the database error condition.
Delete: Remove Records Deliberately
$deleted = $wpdb->delete(
$table,
array('id' => 10),
array('%d')
);
Include a restrictive condition in every delete operation. Before running a bulk delete, run a matching SELECT and inspect the records it would affect. Take a database backup before destructive changes.
Permissions, Nonces, and Escaped Output
Prepared SQL protects the query, but it does not decide who is allowed to run an action. Admin pages should check the appropriate capability, such as manage_options. Forms and AJAX requests should use WordPress nonces to reduce cross-site request forgery risk.
Also verify ownership. A logged-in customer should not be able to modify another customer’s record simply by submitting a different ID. Finally, escape values for their output context with functions such as esc_html() or esc_attr(). Database security and output security are separate concerns.
Debugging $wpdb Queries
Inspect the database error immediately after an operation that may fail:
if (!empty($wpdb->last_error)) {
error_log('Database error: ' . $wpdb->last_error);
}
During development, enable logging without displaying errors to visitors:
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);
$wpdb->last_query can help inspect the most recent query. Use $wpdb->show_errors() only in a controlled development environment. Raw SQL errors should never be exposed publicly because they can reveal table names, queries, or server details.
Typical causes of failure include a misspelled table name, an incorrect prefix, a column type mismatch, insufficient database privileges, and invalid SQL syntax. If the whole site reports a database connection problem, follow a structured WordPress database connection troubleshooting workflow before changing plugin code.
A Practical Development Workflow
- Define the data structure and confirm that an existing WordPress API cannot meet the requirement.
- Create and test the table on staging, using the site’s actual prefix.
- Use prepared statements or the
insert(),update(), anddelete()helpers. - Add capability checks, nonces, validation, ownership checks, and output escaping.
- Test invalid, empty, duplicate, and unexpectedly large inputs.
- Log failures privately and back up the database before schema changes or destructive queries.
Custom plugin and WooCommerce work often crosses several layers: the interface, PHP logic, and database schema. A careful full-stack WordPress development process helps keep those layers consistent and makes database bugs easier to isolate.
Frequently Asked Questions
Should every WordPress database query use $wpdb?
No. Prefer WordPress APIs when they provide the required functionality. Use $wpdb for custom tables and queries that require direct database access.
Does sanitize_text_field() prevent SQL injection?
No. Sanitization and SQL protection solve different problems. Use $wpdb->prepare() or the built-in CRUD helpers when sending values to the database.
Can prepare() use a variable table name?
Not as a normal value placeholder. Build table names only from trusted, fixed code, typically with $wpdb->prefix. Never accept a table name directly from a request.
What if last_error is empty but the result is wrong?
Check the query, parameters, data types, table prefix, and business rules during development. A query can be valid SQL and still return the wrong records because its logic is incorrect.
Conclusion
$wpdb is useful when WordPress’s standard APIs are not enough, but reliable database code requires more than valid SQL. Prepare values, respect the configured table prefix, enforce permissions, escape output, log errors privately, and test destructive operations on staging. When a database issue affects the wider site, use a methodical WordPress fix process to identify the underlying cause before applying a patch.
