☰
WORDPRESS / PRACTICE

Secure SQL Queries in WordPress with $wpdb: prepare(), LIKE, and IN

Secure SQL Queries in WordPress with $wpdb: prepare(), LIKE, and IN

A custom plugin, theme, or mu-plugin often works with data that is not stored in WordPress’s standard tables. In these cases, developers access the database through the global $wpdb object. The main danger is not the SQL query itself, but incorrectly inserting variables into its text.

String concatenation such as "... WHERE id = " . $_GET['id'] can create a path to SQL injection. It does not matter whether a value comes from a form, REST API, cookie, or URL parameter: it must be properly validated and passed to the query before execution. Below, we will cover practical patterns for regular values, LIKE searches, IN lists, and dynamic SQL components.

What Is $wpdb and Why Is Concatenation Dangerous?

The $wpdb object provides methods for running SQL queries against a WordPress database: get_row() for one row, get_results() for a set of rows, get_var() for a single value, and query() for INSERT, UPDATE, or DELETE operations. WordPress also stores standard table names and the database prefix in this object, such as $wpdb->posts or $wpdb->prefix.

An unsafe approach looks like this:

  • User input is inserted directly into the SQL string.
  • The developer relies only on sanitize_text_field() or absint(), even though these functions do not replace SQL query preparation.
  • The same pattern is used for numbers, text, LIKE expressions, and column names without considering their types.
$id = $_GET['id'] ?? 0;

$sql = "SELECT * FROM {$wpdb->prefix}orders WHERE id = $id";
$row = $wpdb->get_row( $sql );

How to Use $wpdb->prepare() for Regular Values

The $wpdb->prepare() method separates the SQL template from the values inserted into it. The template uses placeholders: %d for integers, %f for floating-point numbers, and %s for strings. Values are passed as separate arguments rather than concatenated into the query.

Before preparing the SQL query, it is useful to cast data to the expected type. For example, absint() converts a value into a non-negative integer. This helps prevent logical errors, but in this example, prepare() remains the primary protection against injection.

  • The argument order must match the order of the placeholders.
  • Do not put a placeholder inside quotes manually: prepare() adds the quotes for %s.
  • Do not use %d for arbitrary text or %s where a numeric value is expected.
global $wpdb;

$id = absint( $_GET['id'] ?? 0 );

$row = $wpdb->get_row(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}orders WHERE id = %d",
        $id
    )
);

LIKE Searches: esc_like() First, Then prepare()

For a partial-text search, it is not enough to pass a raw string to %s. SQL LIKE has special characters—% and _—that have a separate meaning because they act as wildcards. If users should be able to search for literal text, these characters must be escaped with the $wpdb->esc_like() method.

The correct order is as follows: obtain the value, normalize it for business logic if necessary, call esc_like(), add % characters for a partial match, and pass the resulting pattern to prepare().

  • esc_like() is intended specifically for part of a LIKE pattern, not for all SQL values.
  • sanitize_text_field() may be appropriate for cleaning input, but it does not replace esc_like() and prepare().
  • If you need to search for exact text only, do not add % at the beginning and end.
global $wpdb;

$term = sanitize_text_field( $_GET['s'] ?? '' );
$like = '%' . $wpdb->esc_like( $term ) . '%';

$rows = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT ID, post_title
         FROM {$wpdb->posts}
         WHERE post_title LIKE %s",
        $like
    )
);

How to Safely Build an IN Clause for an Array of Values

The %s placeholder does not convert an array into a list of SQL values. Passing an array as one argument does not create a valid IN (...). You must create a separate placeholder for each element and then pass the values in the same order.

Before building the query, process each element according to its type. For identifiers, absint() is usually used. Always check for an empty array: IN () is invalid SQL, and it often occurs when this case is not handled.

  • Do not insert implode() with unprocessed values directly into SQL.
  • The number of placeholders must equal the number of arguments.
  • For an array of text values, use %s for each element rather than one %s for the entire array.
global $wpdb;

$ids = array_map( 'absint', (array) $ids );
$ids = array_values( array_filter( $ids ) );

if ( ! $ids ) {
    return [];
}

$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );

$sql = $wpdb->prepare(
    "SELECT ID, post_title
     FROM {$wpdb->posts}
     WHERE ID IN ($placeholders)",
    ...$ids
);

$rows = $wpdb->get_results( $sql );

Table Names, Column Names, and ORDER BY Are Not Values

Placeholders are intended for values, not for table names, column names, or sort directions. For example, attempting to pass a column name through %s does not turn it into an SQL identifier: the database interprets it as a string value.

Dynamic identifiers must come only from a limited list defined in the code. Do not allow users to submit an arbitrary column name or ORDER BY fragment. A table name assembled from a known WordPress prefix is a controlled part of the query; a value from an HTTP request is not.

  • Use an allowlist for columns, such as title => post_title and date => post_date.
  • Validate the sort direction separately and replace it with ASC or DESC.
  • Pass all regular values in WHERE conditions through prepare().
$allowed_order = [
    'title' => 'post_title',
    'date'  => 'post_date',
];

$order_key = sanitize_key( $_GET['order'] ?? 'date' );
$order_by  = $allowed_order[ $order_key ] ?? 'post_date';

$direction = strtoupper( $_GET['direction'] ?? 'DESC' );
$direction = in_array( $direction, [ 'ASC', 'DESC' ], true ) ? $direction : 'DESC';

$sql = "SELECT ID, post_title, post_date
        FROM {$wpdb->posts}
        ORDER BY {$order_by} {$direction}";

INSERT, UPDATE, and DELETE Queries with $wpdb

Use the same principle when writing or modifying data: keep the SQL template separate and pass values through prepare(). The insert() or update() method can be more convenient for simple operations because WordPress builds the query based on the supplied data and formats.

For more complex conditions, you can use query() together with prepare(). After execution, check the result: for query(), it may be the number of affected rows or false; for insert() or update(), false indicates an error.

  • Use the %d format for numeric fields, %f for decimals, and %s for text.
  • Do not build SET or WHERE clauses by concatenating values from a form or API.
  • If zero affected rows are a normal outcome, distinguish that result from false—zero is not always an error.
global $wpdb;

$order_id = absint( $_POST['order_id'] ?? 0 );
$status   = sanitize_key( $_POST['status'] ?? '' );

$result = $wpdb->query(
    $wpdb->prepare(
        "UPDATE {$wpdb->prefix}orders
         SET status = %s
         WHERE id = %d",
        $status,
        $order_id
    )
);

if ( false === $result ) {
    error_log( $wpdb->last_error );
}

Checking Results and Common Mistakes

A secure query is not only about preventing SQL injection. You must also check access permissions, business data validity, and the execution result. For example, absint() does not guarantee that a user is authorized to read an order with a particular ID. Authorization must be performed before accessing the database.

During development, query errors can be diagnosed through $wpdb->last_error. Do not display the database error directly to users in production, because it may reveal table names or details about the database schema.

  • Make $wpdb global where needed, and do not create a separate database connection without a specific reason.
  • Check for empty lists, zero IDs, and missing results from get_row() or get_results().
  • Do not use stripslashes(), addslashes(), or manual escaping as a replacement for prepare().
  • Do not accept the entire SQL query from a REST request or form, even if the endpoint is available only to authenticated users.
  • For critical operations, verify the nonce, capability, and ownership of the object by the current user.
$row = $wpdb->get_row( $sql );

if ( null === $row && ! empty( $wpdb->last_error ) ) {
    error_log( 'Database error: ' . $wpdb->last_error );
}

if ( null === $row ) {
    // The row was not found or an error occurred — handle the scenarios separately.
}

Frequently asked questions

Is sanitize_text_field() enough to protect an SQL query?

No. sanitize_text_field() cleans text according to WordPress rules, but it is not universal protection against SQL injection. Use $wpdb->prepare() to insert values into a query, and apply $wpdb->esc_like() additionally for LIKE expressions.

Can I pass an array of IDs as one %s argument to prepare()?

No. For IN, create a separate %d placeholder for each ID and pass all values as separate arguments. Handle an empty array before building the SQL so you do not create IN ().

Do I need to use prepare() for $wpdb->posts and $wpdb->prefix?

Standard table names and the prefix obtained from controlled $wpdb properties are not user input. They are normally inserted into the SQL template, while all external values are passed through placeholders.

How can I make a dynamic ORDER BY safe?

A column name and sort direction cannot be passed as regular values through %s. Create an allowlist of permitted columns and accept ASC or DESC only after strict validation.

Which $wpdb method should I use for SELECT queries?

Use get_row() for one row, get_results() for a list, and get_var() for one value, such as COUNT(). In every case, prepare SQL containing external values with $wpdb->prepare() first.

Conclusion

Safe work with $wpdb is based on a simple rule: keep the SQL structure in the code, and pass external values through the appropriate placeholders. Use %d for numbers and %s for text; for LIKE, call esc_like() first; and for IN, create a separate placeholder for every element.

Control dynamic column names and sorting separately through an allowlist, check empty arrays and execution results, and handle authorization independently. This keeps $wpdb useful for custom tables and plugins without unsafe SQL concatenation.

← All WordPress materials