$wpdb->prepare()

How prepare() builds a safe query, why string concatenation is an injection, and the %i and LIKE cases.

Syntax$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

Example · php
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.

Example · php
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.

Example · php
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.

Example · php
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

  • Be the first to comment on this lesson.