Running SQL queries in WordPress can be perfectly legitimate when WordPress’s higher-level APIs do not provide the operation you need.
It can also be one of the fastest ways to:
- delete the wrong data;
- create orphaned records;
- bypass WordPress hooks;
- break serialized values;
- introduce SQL injection vulnerabilities;
- make a production site considerably less interesting in the worst possible way.
The important distinction is not:
SQL
=
dangerous
but:
uncontrolled SQL
=
dangerous
WordPress provides the wpdb database abstraction class specifically for interacting with the database from PHP. It includes methods for selecting data, inserting records, updating rows, deleting rows and preparing dynamic SQL safely.
The official wpdb documentation describes it as WordPress’s database access abstraction and explicitly requires untrusted values in SQL statements to be properly escaped.
This guide explains how to safely run SQL queries in WordPress, when direct SQL is appropriate, when a WordPress API is preferable, how $wpdb->prepare() prevents injection, how to work with table prefixes, how to handle LIKE queries, how to inspect errors and how to protect destructive database operations.
Use a WordPress API before writing SQL
The safest database query is often the one you do not need to write.
WordPress already provides APIs for many common operations.
For posts:
get_posts()
WP_Query
wp_insert_post()
wp_update_post()
wp_delete_post()
For metadata:
get_post_meta()
update_post_meta()
delete_post_meta()
get_user_meta()
update_user_meta()
get_term_meta()
update_term_meta()
For options:
get_option()
update_option()
delete_option()
For users:
get_user_by()
WP_User_Query
wp_insert_user()
wp_update_user()
wp_delete_user()
Why APIs are usually preferable
A WordPress function may perform more than a database query.
It may also:
- validate data;
- sanitize values;
- trigger hooks;
- clear caches;
- update related records;
- enforce WordPress semantics;
- maintain metadata relationships.
The official WordPress security documentation recommends using an existing WordPress function where one exists rather than reproducing its behavior with manual SQL.
Direct SQL bypasses application logic
Suppose you want to delete post ID:
123
You could technically execute:
DELETE FROM wp_posts
WHERE ID = 123;
But WordPress normally expects post deletion to involve more than removing one row.
The official wp_delete_post() documentation covers WordPress-aware deletion behavior.
Depending on the content and plugins involved, related systems can include:
- post metadata;
- taxonomy relationships;
- comments;
- attachments;
- object caches;
- plugin hooks.
Direct SQL can bypass that lifecycle.
For a deeper example involving the posts table, see Optimizing the WordPress Posts Table.
When direct SQL is appropriate
Direct SQL can make sense when:
- you need an aggregate query not conveniently provided by WordPress;
- you are reading custom plugin tables;
- you need efficient reporting over large data sets;
- you are performing a controlled migration;
- you are implementing a custom application table;
- you need a query that cannot reasonably be expressed through an existing WordPress API.
A useful rule is:
WordPress API available
→ prefer the API
No suitable API
→ use $wpdb carefully
What is $wpdb?
WordPress exposes a global object:
$wpdb
which is an instance of the:
wpdb
class.
The official wpdb reference documents the methods available for interacting with the active WordPress database connection.
Accessing $wpdb
Inside a function:
function myplugin_example() {
global $wpdb;
// Database operations here.
}
Once declared as global, you can use WordPress database properties and methods.
Do not create another database connection unnecessarily
A common mistake is opening an independent MySQL connection from plugin code:
$mysqli = new mysqli(
'localhost',
'username',
'password',
'database'
);
For ordinary WordPress database operations, this is usually unnecessary.
WordPress already has:
- the configured connection;
- database credentials;
- charset configuration;
- table-prefix information;
- its database abstraction layer.
Use $wpdb unless you intentionally need a separate external database connection.
Never assume the table prefix is wp_
This is one of the most common mistakes in WordPress SQL examples.
This:
SELECT *
FROM wp_posts
assumes the installation uses:
wp_
as its prefix.
WordPress installations can use prefixes such as:
client_
abc_
site1_
production_
Use WordPress table properties
Instead of:
wp_posts
use:
$wpdb->posts
For example:
global $wpdb;
$count = $wpdb->get_var(
"SELECT COUNT(*)
FROM {$wpdb->posts}"
);
Core table properties include
$wpdb->posts
$wpdb->postmeta
$wpdb->users
$wpdb->usermeta
$wpdb->comments
$wpdb->commentmeta
$wpdb->terms
$wpdb->term_taxonomy
$wpdb->term_relationships
$wpdb->options
For custom plugin tables, use $wpdb->prefix
Suppose your plugin creates:
wp_myplugin_events
Do not hardcode the prefix.
Use:
$table = $wpdb->prefix . 'myplugin_events';
Multisite requires additional care
WordPress Multisite can distinguish between:
$wpdb->prefix
and:
$wpdb->base_prefix
depending on whether data belongs to an individual site or the network.
Do not assume a table name strategy from a single-site example when developing network-aware code.
The most important SQL security rule: never concatenate untrusted values
This is dangerous:
$user_id = $_GET['user_id'];
$sql = "
SELECT *
FROM {$wpdb->users}
WHERE ID = $user_id
";
$user = $wpdb->get_row( $sql );
If an attacker controls:
$_GET['user_id']
they may be able to alter the SQL statement.
This is SQL injection
SQL injection happens when external input becomes part of the structure of a query rather than remaining a data value.
Potential consequences can include:
- reading unauthorized data;
- modifying records;
- deleting data;
- bypassing application restrictions;
- compromising sensitive information.
Use $wpdb->prepare()
WordPress provides:
$wpdb->prepare()
for safely inserting dynamic values into SQL statements.
The official wpdb::prepare() documentation describes it as preparing a SQL query for safe execution.
Safe version of the previous query
global $wpdb;
$user_id = isset( $_GET['user_id'] )
? absint( $_GET['user_id'] )
: 0;
$sql = $wpdb->prepare(
"SELECT *
FROM {$wpdb->users}
WHERE ID = %d",
$user_id
);
$user = $wpdb->get_row( $sql );
Available placeholders
Current WordPress supports:
%s
→ string
%d
→ integer
%f
→ floating-point number
%i
→ SQL identifier
The %i placeholder was added to WordPress for safely representing identifiers such as table or field names.
The current prepare() reference documents all four placeholder types.
Do not put quotation marks around placeholders
Correct:
$wpdb->prepare(
"SELECT *
FROM {$wpdb->posts}
WHERE post_status = %s",
'publish'
);
Do not write:
WHERE post_status = '%s'
The current API expects ordinary placeholders to remain unquoted.
Preparing a query does not execute it
This:
$sql = $wpdb->prepare(
"SELECT *
FROM {$wpdb->posts}
WHERE ID = %d",
123
);
produces the prepared SQL string.
You still need an appropriate query method such as:
$wpdb->get_row()
$wpdb->get_var()
$wpdb->get_results()
$wpdb->query()
Use get_var() for one value
If you need one scalar value:
global $wpdb;
$count = $wpdb->get_var(
"SELECT COUNT(*)
FROM {$wpdb->posts}
WHERE post_status = 'publish'"
);
The official get_var() documentation describes it as returning a single value from a database query.
Dynamic version
global $wpdb;
$status = 'draft';
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*)
FROM {$wpdb->posts}
WHERE post_status = %s",
$status
)
);
Use get_row() for one row
global $wpdb;
$post = $wpdb->get_row(
$wpdb->prepare(
"SELECT ID, post_title, post_status
FROM {$wpdb->posts}
WHERE ID = %d",
123
)
);
By default, get_row() returns an object.
You can then access:
$post->ID
$post->post_title
$post->post_status
Use get_col() for one column from multiple rows
For example:
global $wpdb;
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID
FROM {$wpdb->posts}
WHERE post_status = %s",
'draft'
)
);
Use get_results() for multiple rows
The official get_results() documentation defines it as retrieving an entire SQL result set.
Example:
global $wpdb;
$posts = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title
FROM {$wpdb->posts}
WHERE post_status = %s
ORDER BY ID DESC
LIMIT %d",
'publish',
20
)
);
get_results() supports different output formats
Common values include:
OBJECT
ARRAY_A
ARRAY_N
OBJECT_K
For associative arrays:
$rows = $wpdb->get_results(
$sql,
ARRAY_A
);
Use $wpdb->insert() for simple inserts
You do not need to manually construct every INSERT statement.
The official wpdb::insert() documentation provides a structured method.
Example:
global $wpdb;
$table = $wpdb->prefix . 'myplugin_events';
$result = $wpdb->insert(
$table,
array(
'user_id' => 42,
'event_type' => 'login',
),
array(
'%d',
'%s',
)
);
Pass raw values to insert()
Do not SQL-escape the values yourself first.
The insert() API expects raw data and the appropriate formats.
Check the return value
if ( false === $result ) {
// Insert failed.
}
After a successful insert, the generated ID may be available through:
$wpdb->insert_id
where the table uses an auto-incrementing identifier.
Use $wpdb->update() for straightforward updates
global $wpdb;
$table = $wpdb->prefix . 'myplugin_events';
$result = $wpdb->update(
$table,
array(
'event_type' => 'processed',
),
array(
'id' => 100,
),
array(
'%s',
),
array(
'%d',
)
);
This avoids manually constructing:
UPDATE ...
SET ...
WHERE ...
for ordinary updates.
Use $wpdb->delete() for simple deletions
global $wpdb;
$table = $wpdb->prefix . 'myplugin_events';
$result = $wpdb->delete(
$table,
array(
'id' => 100,
),
array(
'%d',
)
);
Prefer WordPress deletion APIs for WordPress-owned objects
This is critical.
For a custom plugin table:
$wpdb->delete()
may be entirely appropriate.
For a WordPress post:
wp_delete_post()
is usually preferable.
For metadata:
delete_post_meta()
delete_user_meta()
delete_term_meta()
are usually preferable.
$wpdb->query() is the general-purpose method
The query() method can execute arbitrary SQL.
For example:
global $wpdb;
$result = $wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta}
WHERE meta_key = %s",
'_temporary_key'
)
);
The official wpdb documentation recommends the more specialized methods for ordinary operations and query() when a custom or more complex statement is actually required.
Check query() results with strict comparison
This matters because:
false
can mean:
query error
while:
0
can legitimately mean:
query succeeded
but affected zero rows
Correct:
if ( false === $result ) {
// Database error.
} elseif ( 0 === $result ) {
// Query succeeded but changed nothing.
}
Do not collapse these states with a loose check such as:
if ( ! $result )
Never concatenate strings into WHERE conditions
Unsafe:
$email = $_POST['email'];
$sql = "
SELECT ID
FROM {$wpdb->users}
WHERE user_email = '$email'
";
Safe:
$email = sanitize_email(
wp_unslash(
$_POST['email'] ?? ''
)
);
$sql = $wpdb->prepare(
"SELECT ID
FROM {$wpdb->users}
WHERE user_email = %s",
$email
);
Sanitization and SQL preparation are different things
This distinction is essential.
Sanitization answers:
Is this value appropriate
for the application?
Preparation answers:
Can this value alter
the SQL query structure?
Use both where appropriate
For example:
$user_id = absint(
$_POST['user_id'] ?? 0
);
$sql = $wpdb->prepare(
"SELECT *
FROM {$wpdb->users}
WHERE ID = %d",
$user_id
);
absint() normalizes the application value.
%d safely inserts it into SQL.
Do not use esc_html() for SQL
This:
esc_html()
exists for HTML output.
It is not a database-query escaping function.
Security escaping must match the destination context.
LIKE queries require special handling
SQL LIKE uses special wildcard characters:
%
_
If the search text comes from a user, use:
$wpdb->esc_like()
before preparing the query.
The official esc_like() documentation explicitly states that it is the first stage of escaping for SQL LIKE patterns and should be followed by prepare().
Correct LIKE example
global $wpdb;
$search = sanitize_text_field(
wp_unslash(
$_GET['search'] ?? ''
)
);
$like = '%' .
$wpdb->esc_like( $search ) .
'%';
$sql = $wpdb->prepare(
"SELECT ID, post_title
FROM {$wpdb->posts}
WHERE post_title LIKE %s",
$like
);
$results = $wpdb->get_results( $sql );
Do not put the wildcard into the SQL placeholder itself
A fragile pattern is:
LIKE '%%%s%%'
Instead, create the complete search pattern as a value:
$like = '%' .
$wpdb->esc_like( $search ) .
'%';
and pass that value through:
%s
Literal percentage signs require care in prepared SQL
Because prepare() uses sprintf-style placeholders, literal percent characters inside the SQL query string may need:
%%
The official prepare() reference documents this behavior.
Dynamic table and column names are different from values
You cannot safely treat an identifier exactly like a normal string value.
For example:
SELECT *
FROM some_table
contains:
some_table
as an SQL identifier.
Modern WordPress supports the %i placeholder
For supported WordPress versions:
$sql = $wpdb->prepare(
"SELECT *
FROM %i
WHERE %i = %s",
$table,
$column,
$value
);
The official prepare() documentation notes that %i was added for SQL identifiers.
Identifier support can be checked
WordPress documents:
$wpdb->has_cap(
'identifier_placeholders'
)
for checking support where backward compatibility matters.
Whitelist dynamic identifiers whenever possible
Even with safe identifier handling, your application should generally restrict table and column choices to known values.
For example:
$allowed_columns = array(
'ID',
'post_title',
'post_date',
);
if (
! in_array(
$requested_column,
$allowed_columns,
true
)
) {
return;
}
Do not let users decide arbitrary SQL structure
A user may legitimately control:
search term
date
status
page number
They usually should not control:
raw WHERE clause
raw ORDER BY clause
raw JOIN
raw table name
arbitrary SQL expression
ORDER BY deserves special attention
You may be tempted to write:
ORDER BY {$_GET['order']}
That is unsafe.
Instead:
$allowed_orderby = array(
'post_date',
'post_title',
'ID',
);
$orderby = sanitize_key(
$_GET['orderby'] ?? 'post_date'
);
if (
! in_array(
$orderby,
$allowed_orderby,
true
)
) {
$orderby = 'post_date';
}
Then use the validated identifier appropriately.
LIMIT and OFFSET should be integers
For pagination:
$limit = 20;
$offset = 40;
$sql = $wpdb->prepare(
"SELECT ID, post_title
FROM {$wpdb->posts}
ORDER BY ID DESC
LIMIT %d OFFSET %d",
$limit,
$offset
);
Avoid SELECT * when you do not need every column
Instead of:
SELECT *
FROM wp_posts
prefer:
SELECT ID, post_title
FROM wp_posts
when those are the only fields required.
Why this matters
Selecting only necessary data can reduce:
- database transfer;
- PHP memory usage;
- object size;
- unnecessary processing.
Always constrain potentially large queries
This can be dangerous on a large site:
SELECT *
FROM wp_postmeta;
A site may contain millions of metadata rows.
Add appropriate filters and limits
For diagnostic inspection:
SELECT meta_id, post_id, meta_key
FROM wp_postmeta
WHERE meta_key = '_example'
ORDER BY meta_id DESC
LIMIT 100;
Do not load huge result sets into PHP unnecessarily
If you only need:
the number of matching rows
use:
COUNT(*)
rather than loading every matching record and calling:
count( $rows )
Do not assume direct SQL is automatically faster than WordPress APIs
A badly designed custom query can be substantially worse than the WordPress API it replaces.
Performance depends on:
- indexes;
- join strategy;
- filter selectivity;
- data volume;
- query frequency;
- result size.
See Reducing WordPress Admin Server Load for the broader process of profiling actual database work.
Use EXPLAIN when investigating expensive SELECT queries
MySQL and MariaDB provide:
EXPLAIN
for inspecting how a query is executed.
For example:
EXPLAIN
SELECT ID
FROM wp_posts
WHERE post_status = 'publish'
AND post_type = 'post';
This can help identify:
- which indexes are considered;
- which index is selected;
- estimated rows examined;
- join order;
- possible full-table scans.
EXPLAIN is safer than experimenting with indexes blindly
Do not create database indexes simply because a query looks complicated.
First determine:
which query is slow
how often it runs
which indexes already exist
whether the proposed index improves it
SELECT queries can still cause operational problems
A query does not need to modify data to be dangerous.
For example:
SELECT *
FROM enormous_table
ORDER BY unindexed_column;
can consume substantial:
- CPU;
- memory;
- temporary disk;
- database time.
Run expensive diagnostics on staging where possible
For complex or unfamiliar queries:
production database clone
↓
staging
↓
test query
↓
measure behavior
↓
production only if appropriate
See WordPress Staging Site Best Practices for the wider environment strategy.
Back up before destructive SQL
Before running:
DELETE
UPDATE
ALTER
DROP
TRUNCATE
against important production data, create an appropriate recovery point.
The official WordPress database backup documentation explains the role of database backups in protecting WordPress data.
TheOneWP Backup Manager can provide a database-only or broader complete backup before destructive database maintenance.
Before DELETE, run the equivalent SELECT
If you intend to execute:
DELETE FROM wp_postmeta
WHERE meta_key = '_old_plugin_data';
first run:
SELECT meta_id, post_id, meta_key
FROM wp_postmeta
WHERE meta_key = '_old_plugin_data'
LIMIT 100;
Confirm that the records are actually the ones you intend to remove.
Count the affected rows first
SELECT COUNT(*)
FROM wp_postmeta
WHERE meta_key = '_old_plugin_data';
If you expected:
42 rows
and the result is:
2,400,000 rows
that is an excellent moment not to press Enter on the delete statement.
Apply the same principle to UPDATE
Before:
UPDATE table
SET status = 'archived'
WHERE ...;
inspect:
SELECT *
FROM table
WHERE ...;
Be careful with queries missing WHERE clauses
This:
UPDATE wp_posts
SET post_status = 'draft';
means:
update every row
This:
DELETE FROM wp_postmeta;
means:
delete every row
Destructive queries deserve explicit safeguards
When implementing database tools, consider:
- confirmation screens;
- record previews;
- affected-row counts;
- capability checks;
- nonces;
- backups;
- staging tests.
Capability checks are mandatory for administration tools
If an admin page allows a user to run database operations, being logged in is not enough.
Check an appropriate capability.
For example:
if (
! current_user_can(
'manage_options'
)
) {
wp_die(
esc_html__(
'You are not allowed to perform this operation.',
'myplugin'
)
);
}
The capability should reflect the actual operation rather than being chosen merely because it is convenient.
Nonces protect requests from CSRF
Administrative write operations should also use a nonce.
The official WordPress nonce documentation explains that nonces help protect against cross-site request forgery.
However, WordPress explicitly warns that nonces are not authorization.
You still need:
current_user_can()
A safe admin operation requires both
authenticated user
+
capability check
+
nonce validation
+
safe SQL
Example destructive admin action
if (
! current_user_can(
'manage_options'
)
) {
wp_die(
esc_html__(
'Permission denied.',
'myplugin'
)
);
}
check_admin_referer(
'myplugin_cleanup'
);
global $wpdb;
$table = $wpdb->prefix . 'myplugin_logs';
$result = $wpdb->query(
$wpdb->prepare(
"DELETE FROM %i
WHERE created_at < %s",
$table,
gmdate(
'Y-m-d H:i:s',
strtotime( '-90 days' )
)
)
);
Never expose a generic SQL console to ordinary administrators casually
An interface that accepts:
arbitrary SQL text
effectively grants database-level power through WordPress.
That is much more dangerous than exposing a specific controlled operation such as:
delete expired plugin logs
Prefer task-specific database tools
Instead of:
Run SQL:
[_______________________]
prefer:
Expired records found:
18,420
[Preview records]
[Delete expired records]
The application can then control exactly which SQL is allowed.
TheOneWP Database Manager
TheOneWP Database Manager provides visibility into WordPress database tables, structures and records.
Inspection should normally come before modification.
A database interface is most useful when it makes questions such as these easier to answer:
- Which table am I changing?
- How many records are affected?
- What do those records contain?
- Which plugin appears to own them?
- Is there a current backup?
Database Manager does not remove the need to understand SQL semantics
A graphical interface can reduce operational friction.
It cannot make an incorrect database operation logically correct.
Database Optimizer is preferable for known cleanup categories
If the task is ordinary maintenance such as supported cleanup of known disposable records, TheOneWP Database Optimizer provides targeted cleanup workflows rather than requiring an administrator to manually compose SQL.
For the wider cleanup problem, see WordPress Database Bloat Explained.
Do not modify serialized values with naive SQL
WordPress options and plugin data can contain serialized PHP structures.
A query such as:
UPDATE wp_options
SET option_value =
REPLACE(
option_value,
'old-domain.com',
'new-longer-domain.com'
);
can corrupt serialized values because serialized strings contain length information.
For domain migrations, use serialization-aware tooling.
See Why Serialized Data Breaks Naive WordPress Migrations and Preparing a WordPress Database for Migration.
Do not manually modify cached aggregate values without understanding their source
WordPress stores some derived values in the database.
An example is:
wp_term_taxonomy.count
If you manually change an aggregate value, WordPress may recalculate it later from the underlying relationships.
Fix the underlying application state rather than treating every stored number as independent source data.
Direct database updates can bypass object-cache invalidation
WordPress APIs frequently maintain cached representations of stored data.
If you update a record directly in SQL, an object already cached during the request or in a persistent cache may not automatically know about the change.
This can produce confusing behavior
database
→ new value
persistent object cache
→ old value
application
→ appears unchanged
This is another reason to use WordPress APIs for WordPress-owned data when available.
Do not log sensitive SQL indiscriminately
Queries can contain:
- email addresses;
- personal data;
- API credentials;
- private metadata;
- authentication-related information.
Database debugging should not create a second uncontrolled copy of sensitive information inside log files.
Do not display raw SQL errors to visitors
Detailed database errors can reveal:
- table names;
- column names;
- database structure;
- query contents;
- internal application behavior.
Production users generally need a safe application error, while developers can inspect protected logs.
wpdb exposes database-error information
Useful properties include:
$wpdb->last_error
$wpdb->last_query
$wpdb->num_rows
$wpdb->rows_affected
$wpdb->insert_id
Use them carefully during development and troubleshooting.
Example error handling
global $wpdb;
$result = $wpdb->delete(
$wpdb->prefix . 'myplugin_events',
array(
'id' => 123,
),
array(
'%d',
)
);
if ( false === $result ) {
error_log(
'MyPlugin database operation failed.'
);
}
Avoid dumping the complete raw query into public output.
Batch large modifications
Suppose you need to process:
800,000 records
Doing all of them in one web request can cause:
- PHP timeouts;
- database locks;
- memory exhaustion;
- long administration requests;
- difficult recovery.
Process predictable batches
For example:
1,000 rows
↓
commit application progress
↓
next 1,000
↓
continue
The exact batch size depends on the operation and environment.
WP-CLI is often better for large maintenance operations
Long-running database maintenance can be more appropriate through:
WP-CLI
rather than an HTTP request that depends on:
- browser connection;
- PHP web timeout;
- reverse-proxy timeout;
- administrator waiting with a spinning interface.
Do not assume a database transaction makes every WordPress operation atomic
MySQL or MariaDB transactional behavior depends on the tables and storage engine involved.
WordPress’s wpdb class does not provide a high-level universal transaction abstraction comparable to its CRUD helpers.
Developers sometimes execute SQL such as:
START TRANSACTION
COMMIT
ROLLBACK
through $wpdb->query(), but doing so responsibly requires understanding:
- the database engine;
- which tables participate;
- implicit commits caused by some statements;
- external side effects;
- WordPress hooks;
- application-level state.
A transaction cannot undo an email or external API call
Suppose code:
updates database
↓
calls external API
↓
sends email
↓
later rolls back database
The external API request and email are not automatically rolled back.
Database transactions solve database consistency problems, not every application side effect.
Be careful with raw SQL migrations inside plugin activation
For plugin-owned schema creation and updates, WordPress provides tools such as:
dbDelta()
for managing table definitions in supported plugin workflows.
Schema migrations should:
- be versioned;
- be repeatable where possible;
- avoid destructive changes without backup;
- handle failure clearly;
- be tested on real data volumes.
Never modify WordPress Core tables casually
Core tables have established semantics used by:
- WordPress itself;
- plugins;
- themes;
- REST endpoints;
- caches;
- WP-CLI.
Adding arbitrary columns or indexes can create maintenance and compatibility responsibilities.
Custom application data may deserve a custom table
If your plugin needs to store millions of structured records with query patterns unrelated to posts or metadata, forcing everything into:
wp_posts
wp_postmeta
may not be the best architecture.
A custom table can be appropriate when the data model genuinely requires it.
Then your plugin owns that schema
That means you are responsible for:
- creation;
- indexes;
- upgrades;
- queries;
- cleanup;
- compatibility;
- uninstall behavior.
A safe SQL workflow
For any non-trivial database operation, use a process like:
Define objective
↓
Check for WordPress API
↓
Identify tables and relationships
↓
Create backup
↓
Test SELECT version
↓
Count affected records
↓
Test on staging
↓
Prepare dynamic values
↓
Run controlled operation
↓
Check result
↓
Clear relevant caches
↓
Verify application behavior
Safe SELECT example
global $wpdb;
$status = 'publish';
$limit = 20;
$posts = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title, post_date
FROM {$wpdb->posts}
WHERE post_status = %s
AND post_type = %s
ORDER BY post_date DESC
LIMIT %d",
$status,
'post',
$limit
)
);
Safe custom-table insert example
global $wpdb;
$table = $wpdb->prefix . 'myplugin_logs';
$result = $wpdb->insert(
$table,
array(
'user_id' => get_current_user_id(),
'event_type' => 'export',
'created_at' => current_time(
'mysql',
true
),
),
array(
'%d',
'%s',
'%s',
)
);
if ( false === $result ) {
// Handle the error safely.
}
Safe custom-table update example
global $wpdb;
$table = $wpdb->prefix . 'myplugin_jobs';
$result = $wpdb->update(
$table,
array(
'status' => 'complete',
),
array(
'id' => 55,
),
array(
'%s',
),
array(
'%d',
)
);
Safe LIKE example
global $wpdb;
$search = 'example';
$like = '%' .
$wpdb->esc_like( $search ) .
'%';
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title
FROM {$wpdb->posts}
WHERE post_title LIKE %s
LIMIT %d",
$like,
50
)
);
Safe SQL checklist for WordPress developers
- Use a WordPress API when one already performs the required operation.
- Use
$wpdbrather than opening another WordPress database connection. - Never assume the table prefix is
wp_. - Use
$wpdb->postsand other Core table properties. - Use
$wpdb->prefixfor site-specific custom tables. - Understand
$wpdb->base_prefixin Multisite. - Never concatenate untrusted values into SQL.
- Use
$wpdb->prepare()for dynamic values. - Use
%sfor strings. - Use
%dfor integers. - Use
%ffor floating-point values. - Use
%ifor identifiers where supported and appropriate. - Leave normal placeholders unquoted.
- Whitelist dynamic columns and sort fields.
- Use
$wpdb->esc_like()beforeprepare()for LIKE searches. - Use strict comparison when checking query results.
- Use
get_var()when you need one value. - Use
get_row()when you need one row. - Use
get_col()when you need one column. - Use
get_results()when you need multiple rows. - Use
insert(),update()anddelete()for straightforward CRUD against custom tables. - Select only the columns you need.
- Limit potentially large result sets.
- Profile expensive queries rather than guessing.
- Use
EXPLAINfor complex SELECT performance investigations. - Run destructive operations on staging first where practical.
- Create a backup before high-risk database modifications.
- Run the equivalent SELECT before DELETE or UPDATE.
- Count affected rows before destructive changes.
- Check capabilities before exposing database operations in wp-admin.
- Validate nonces for state-changing administration requests.
- Remember that a nonce is not authorization.
- Do not expose arbitrary SQL execution to ordinary users.
- Do not modify serialized data with naive replacement SQL.
- Remember that direct SQL can bypass WordPress caches.
- Do not display raw database errors publicly.
- Batch very large modifications.
- Consider WP-CLI for long-running maintenance.
- Verify application behavior after every significant database change.
Related guides
- How to Manage the WordPress Database without phpMyAdmin
- Optimizing the WordPress Posts Table
- WordPress Database Bloat Explained
- Preparing a WordPress Database for Migration
- WordPress User Meta Explained
- WordPress Staging Site Best Practices
Final recommendation
Running SQL directly in WordPress is not inherently unsafe. Running SQL without respecting WordPress’s data model, query APIs and security boundaries is.
The safest hierarchy is:
WordPress API
↓
wpdb CRUD method
↓
prepared custom SQL
↓
raw database maintenance only when genuinely necessary
Use existing WordPress APIs for WordPress-owned objects whenever possible because those APIs can maintain relationships, hooks and caches that a direct SQL statement cannot see.
When custom SQL is necessary, use $wpdb, use the correct table prefix, pass dynamic values through $wpdb->prepare(), use esc_like() for search patterns and restrict dynamic identifiers to known values.
For destructive operations, inspect the target records first, count what will change, create a current backup and test the process away from production whenever practical.
And remember that SQL safety has two separate dimensions: the query must be secure against injection, but it must also be logically correct for the WordPress data it modifies. A perfectly prepared DELETE statement can still delete exactly the wrong records with impeccable security.

