Opens in a new tab
  1. Home
  2. Guides
  3. System
System guide

How to Safely Run SQL Queries in WordPress

Learn how to safely run SQL queries in WordPress using $wpdb, prepared statements, secure placeholders, correct table prefixes, controlled CRUD operations and safer workflows for destructive database changes.

  • Updated September 13, 2026
  • 23 min read
  • WordPress guide

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 $wpdb rather than opening another WordPress database connection.
  • Never assume the table prefix is wp_.
  • Use $wpdb->posts and other Core table properties.
  • Use $wpdb->prefix for site-specific custom tables.
  • Understand $wpdb->base_prefix in Multisite.
  • Never concatenate untrusted values into SQL.
  • Use $wpdb->prepare() for dynamic values.
  • Use %s for strings.
  • Use %d for integers.
  • Use %f for floating-point values.
  • Use %i for identifiers where supported and appropriate.
  • Leave normal placeholders unquoted.
  • Whitelist dynamic columns and sort fields.
  • Use $wpdb->esc_like() before prepare() 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() and delete() for straightforward CRUD against custom tables.
  • Select only the columns you need.
  • Limit potentially large result sets.
  • Profile expensive queries rather than guessing.
  • Use EXPLAIN for 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

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.

Simplify your WordPress stack

A modular WordPress toolkit. 104 focused tools.

Ultimately, you can build cleaner workflows, maintain fewer plugins and enable only the features each website actually needs.