$wpdb->prepare()
How prepare() builds a safe query, why string concatenation is an injection, and the %i and LIKE cases.
$wpdb->prepare( string $query, mixed ...$args ): string$wpdb->prepare() takes a query containing sprintf()-style placeholders plus the values to substitute, and returns a string with every value escaped for the current connection. It is not a server-side prepared statement — it is interpolation done correctly, once, by code that knows the connection charset.
Placeholders
%d integer, %f float, %s string, and %i for an identifier such as a table or column name, added in WordPress 6.2. Leave placeholders unquoted: prepare() adds the quotes around %s and the backticks around %i. A literal percent sign in the query must be written %%. Every placeholder needs exactly one argument, or you get a _doing_it_wrong() notice.
Arguments come either as individual arguments or as one array — the two forms are the same thing, and mixing them is not supported.
Why concatenation is injection
Any value pasted into the SQL string is executed as SQL. WHERE post_type = '$type' lets a request containing ' OR 1=1 -- rewrite the query. It makes no difference that the value came from an admin screen, a cookie, or a shortcode attribute; the fix is always a placeholder.
LIKE
Wildcards cannot live in the query string. Run user input through $wpdb->esc_like() — it escapes %, _ and backslash — then concatenate the wildcards you actually want onto that value and pass the whole thing as a %s argument.
Example
global $wpdb;
// Every variable enters through a placeholder. Do not quote %s yourself -
// prepare() adds the quotes, and LIMIT %d is fine because %d is not quoted.
$sql = $wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts}
WHERE post_type = %s
AND post_status = %s
AND post_date > %s
ORDER BY post_date DESC
LIMIT %d",
'product',
'publish',
'2026-01-01 00:00:00',
20
);
$rows = $wpdb->get_results( $sql );
// NEVER do this. $_GET['type'] is concatenated into the statement, so a
// request of ?type=x' OR 1=1 -- executes as SQL and the WHERE disappears.
$rows = $wpdb->get_results(
"SELECT ID FROM {$wpdb->posts} WHERE post_type = '" . $_GET['type'] . "'"
);
// The table name is interpolated above and that is safe - it comes from
// $wpdb, not from a request. Only untrusted input needs a placeholder.When to use it
- Filtering any query by a value that came from a request, a form, or a shortcode attribute.
- Building an IN clause from an array of post or user IDs.
- Sorting an admin list table by a column name the user picked.
- Powering an admin search box with LIKE without letting a stray underscore match everything.
More examples
%i for identifiers, and the pre-6.2 longhand
Reach for %i whenever a column or table name is decided at runtime — sortable admin tables, generic CRUD helpers, migration scripts. Combine it with an allowlist so an invalid name fails in your code rather than in MySQL.
global $wpdb;
$column = $_GET['sort'] ?? 'post_date'; // Chosen by the caller.
// WordPress 6.2+: %i escapes the identifier and wraps it in backticks.
$sql = $wpdb->prepare(
"SELECT %i FROM %i WHERE ID = %d",
$column,
$wpdb->posts,
$post_id
);
// Longhand for installs older than 6.2: an allowlist, never interpolation.
// This is still worth doing on 6.2+ because %i will happily accept a
// column name that does not exist and hand you a MySQL error instead.
$allowed = array( 'post_title', 'post_name', 'post_date' );
$column = in_array( $column, $allowed, true ) ? $column : 'post_date';
$sql = $wpdb->prepare(
"SELECT `{$column}` FROM {$wpdb->posts} WHERE ID = %d",
$post_id
);
// Feature-detect when you still support older installs.
if ( $wpdb->has_cap( 'identifier_placeholders' ) ) {
// %i is available on this install.
}
// One argument cannot serve as both an identifier and a value; doing so
// triggers a _doing_it_wrong() notice and prepare() returns nothing.Searching with LIKE without handing over wildcards
Any admin search box or autocomplete endpoint needs this pair. Without esc_like() a user typing a single underscore or percent turns a targeted lookup into a full-table match, which is a performance problem before it is a correctness one.
global $wpdb;
$search = isset( $_GET['s'] ) ? wp_unslash( $_GET['s'] ) : '';
// esc_like() escapes % and _ so a search for "50%" or "draft_v1" is taken
// literally instead of matching everything. The wildcards you DO want are
// concatenated onto the argument, never written into the query string.
$like = '%' . $wpdb->esc_like( $search ) . '%';
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts}
WHERE post_title LIKE %s
AND post_status = 'publish'
ORDER BY post_date DESC
LIMIT 50",
$like
)
);
// Wrong two ways: the wildcards are in the query string, and % is not
// escaped in the user's input, so "%" as a search matches every row.
$bad = $wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE post_title LIKE '%%%s%%'",
$search
);An IN clause with a variable number of values
The canonical way to prepare an IN clause. The generated placeholder string is safe because it contains only literal %d tokens you produced — nothing from user input reaches the query string.
global $wpdb;
$ids = array_map( 'intval', $ids ); // Still validate the shape yourself.
if ( empty( $ids ) ) {
return array(); // "IN ()" is a syntax error - guard for it.
}
// Build one %d per element, then pass the values as a single array.
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE ID IN ({$placeholders})",
$ids // Array form.
)
);
// The array form and the individual-argument form are the same call:
$rows = $wpdb->get_results(
$wpdb->prepare( "... WHERE ID IN (%d,%d,%d)", 12, 45, 78 )
);
// ...but only the array form works for a list whose length is not known
// when you write the code. Mixing the two forms is not supported.
Discussion