WordPressの $wpdb で直接SQLを書く — プレースホルダは4種類あり、引用符で囲んではいけない

WordPressの $wpdb で直接SQLを書く — プレースホルダは4種類あり、引用符で囲んではいけない

先に結論

「先に結論」の内容を図にまとめたもの

$wpdb->prepare() のプレースホルダを %s と %d の2つだと思っているなら、 情報が2023年より前のものです。いまは4種類あります。

表1:プレースホルダ(WordPress公式リファレンス)
記号意味追加
%dinteger(整数)最初から
%sstring(文字列)最初から
%ffloat(小数)最初から
%iidentifier(テーブル名・カラム名)6.2.0

そして、書き方に強い決まりがあります。

「すべてのプレースホルダは、クエリ文字列の中で引用符で囲まないままにしなければなりません。 各プレースホルダには、対応する引数を必ず渡さなければなりません」
— wpdb::prepare()(WordPress 公式リファレンス・要約)

'%s' のようにシングルクォートで囲む書き方は間違いです。 prepare() が自分で引用符を付けるので、二重に囲むことになります。

表2:よくある書き方の正誤
書き方
正WHERE post_title = %s
誤WHERE post_title = '%s'
正FROM %i(6.2以降)
誤FROM `%s`

そもそも $wpdb を使うべきか

「そもそも $wpdb を使うべきか」の内容を図にまとめたもの

最初に確認してください。 投稿やユーザーを扱いたいだけなら、SQLを書かずに済むことがほとんどです。

表3:SQLを書く前に検討するもの
やりたいこと使うもの$wpdb が要るか
条件に合う投稿を取るWP_Query / get_posts()要らない
カスタムフィールドで絞るWP_Query の meta_query要らない
タクソノミーで絞るWP_Query の tax_query要らない
ユーザーを取るWP_User_Query要らない
自作テーブルを読み書きする$wpdb要る
集計する(COUNT・GROUP BY・JOIN)$wpdb要る
大量データを一括更新する$wpdb要ることが多い

上5行を $wpdb でやると損をします。 キャッシュもフックも効かなくなり、プラグインとの相性も悪くなります。 $wpdb が本領を出すのは、下3行のような「標準APIでは書けないクエリ」です。

使うときは、グローバルの $wpdb を受け取ります。公式も、自分で new しないよう書いています。

function my_count_published() {
    global $wpdb;                       // 公式:new wpdb せず、グローバルを使う
    return (int) $wpdb->get_var(
        "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish'"
    );
}

prepare() の書き方

「prepare() の書き方」の内容を図にまとめたもの

プレースホルダは4種類ある

%f と %i を書いている解説はほとんどありません。 公式リファレンスには4つとも載っています(表1)。

global $wpdb;

$sql = $wpdb->prepare(
    "SELECT * FROM {$wpdb->postmeta}
     WHERE post_id = %d
       AND meta_key = %s
       AND meta_value > %f",
    $post_id, $key, $threshold
);
$rows = $wpdb->get_results( $sql );

prepare() は文字列を返すだけです。 実行はしません。返ってきたSQLを get_results() などに渡します。

引用符で囲んではいけない

ここが一番間違えられています。 公式リファレンスの表現は強く、「MUST(しなければならない)」が使われています。

表4:引用符を付けるとどうなるか
書き方結果
= %sprepare() が引用符を付ける。正しい
= '%s'引用符が二重になる。意図しない値になる
LIKE '%%%s%%'古い記事に多い形。いまは LIKE %s と esc_like() を使う

部分一致は、エスケープ用の関数と組み合わせます。

global $wpdb;

// % と _ をエスケープしてから、前後に % を付ける
$like = '%' . $wpdb->esc_like( $keyword ) . '%';

$sql = $wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s",
    $like                      // ← プレースホルダは囲まない
);

esc_like() を忘れると、利用者が入力した % がワイルドカードとして効きます。 検索語に % を入れただけで全件返る、という挙動になります。

テーブル名を渡せるようになった

WordPress 6.2 で %i が追加されました。 公式のChangelogにこう書かれています。

6.2.0:「識別子(テーブル名やフィールド名など)のために %i を追加。 対応しているかは wpdb::has_cap( 'identifier_placeholders' ) で確認できる」
— wpdb::prepare() Changelog(WordPress 公式・要約)

それ以前は、テーブル名やカラム名を変数で渡す安全な方法がありませんでした。 だから古い記事はテーブル名を文字列に直接埋め込んでいます。 固定の文字列なら問題ありませんが、外から来る値だと危険でした。

global $wpdb;

// 6.2 以降:カラム名も安全に渡せる
$sql = $wpdb->prepare(
    "SELECT * FROM %i ORDER BY %i DESC LIMIT %d",
    $wpdb->posts, $order_column, $limit
);

// 対応しているか確認してから使う
if ( $wpdb->has_cap( 'identifier_placeholders' ) ) {
    // %i が使える
}
表5:テーブル名の渡し方
状況書き方
固定のテーブル名{$wpdb->posts} を埋め込む。これで十分
変数で決まるカラム名%i を使う(6.2以降)
6.1以前もサポートするhas_cap() で分岐するか、許可リストで検証する

型が合わないと値が変わる

%d に文字列を渡してもエラーにはなりません。 変換されて別の値になります。公式リファレンスが具体例を載せています。

表6:%d に渡した値と結果(公式の例)
渡した値結果
1"1"
1.1"1"(小数が落ちる)
'string'"0"
NULL"0"
false"0"

3行目が怖いところです。 文字列を %d に渡すと 0 になります。 「0件返る」「IDが0のレコードを探している」という症状は、だいたいこれです。 小数を扱うなら %f、NULLを入れたいなら prepare() では表現できないので、 クエリ側で IS NULL を書きます。

取得メソッドの使い分け

「取得メソッドの使い分け」の内容を図にまとめたもの

欲しい形に合わせて選びます。どれも同じSQLを渡せますが、返ってくる形が違います。

表7:取得メソッド(WordPress公式)
メソッド返るもの使う場面
get_results()行の配列複数行・複数カラム
get_row()1行1件だけ取る
get_col()1列(値の配列)IDの一覧など
get_var()値1つCOUNT(*) など

get_var() を使うべき場面で get_results() を使うのは、よくある遠回りです。 件数が欲しいだけなら、配列を受け取って1つ目の要素を取り出す必要はありません。

global $wpdb;

// 件数:get_var
$count = (int) $wpdb->get_var(
    $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s", $type )
);

// IDの一覧:get_col
$ids = $wpdb->get_col(
    $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_status = %s", 'publish' )
);

// 1行:get_row
$row = $wpdb->get_row(
    $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE ID = %d", $id )
);

書き込み系メソッド

「書き込み系メソッド」の内容を図にまとめたもの

書き込みは、SQLを書かずに済む方法があります。 insert() / update() / delete() は、配列で渡せばエスケープもやってくれます。

表8:書き込み系メソッド(WordPress公式)
メソッド役割prepare()
insert()1行追加する不要
update()既存の行を更新する不要
delete()行を削除する不要
replace()追加、または更新する不要
query()任意のSQLを実行する必要
global $wpdb;

// 配列で渡せば、SQLもエスケープも書かなくてよい
$wpdb->insert(
    $wpdb->prefix . 'my_table',
    array( 'post_id' => $post_id, 'score' => $score ),   // データ
    array( '%d', '%f' )                                   // 書式(省略可だが明示する)
);

$new_id = $wpdb->insert_id;   // 追加された行のID

第3引数の書式は省略できますが、書いてください。 省略するとすべて %s として扱われ、数値カラムに文字列が入ります。

query() だけは別格です。 任意のSQLをそのまま実行するので、変数を埋め込むなら必ず prepare() を通してください。 ここを素通りさせるのが、SQLインジェクションの入口です。

テーブル名の組み立て

「テーブル名の組み立て」の内容を図にまとめたもの

WordPressのテーブル名には接頭辞が付きます。 既定は wp_ ですが、変えられるのでハードコードしてはいけません。

表9:テーブル名に使うプロパティ
書き方指すもの
$wpdb->posts投稿テーブル(接頭辞つきで解決済み)
$wpdb->postmetaカスタムフィールド
$wpdb->users / $wpdb->usermetaユーザー
$wpdb->options設定
$wpdb->prefixそのサイトの接頭辞。自作テーブルに使う
$wpdb->base_prefixブログ番号を含まない元の接頭辞

最後の2つの違いは、マルチサイトで効いてきます。 prefix はサイトごとに wp_2_ のような形になりますが、 base_prefix は wp_ のままです。 サイトごとに分けたいデータは prefix、全体で共有するなら base_prefix です。

詰まったときに見るもの

「詰まったときに見るもの」の内容を図にまとめたもの

$wpdb は失敗しても例外を投げません。 false や空配列が返るだけなので、原因を自分で取りに行きます。

表10:確認する順番
順見るもの分かること
1$wpdb->last_query実際に投げたSQL。プレースホルダが展開された形
2$wpdb->last_errorデータベースが返したエラー
3$wpdb->show_errors()エラーを画面に出す(公式のメソッド)
4$wpdb->print_error()直近のエラーを出力する
5$wpdb->num_rows取得できた行数

1行目を先に見てください。 last_query にはプレースホルダが置き換わったあとのSQLが入っています。 %d に文字列を渡して 0 になっていれば、ここで見えます(表6)。

global $wpdb;

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

if ( $wpdb->last_error ) {
    error_log( 'SQL: ' . $wpdb->last_query );
    error_log( 'ERR: ' . $wpdb->last_error );
}

show_errors() を本番で有効にしないでください。 データベースのエラーが画面に出ます。 出力が混ざると、JSONを返すエンドポイントなら Unexpected token '<' になり、 HTMLなら文字化けの原因にもなります。 ログに出してください。

AIに任せるときの注意

「AIに任せるときの注意」の内容を図にまとめたもの
表11:AIへの頼み方
頼むこと向くか理由
生SQLを貼って「prepareを使う形に直して」向く機械的な変換。漏れも見つけやすい
「この $wpdb のコードを WP_Query に置き換えられる?」向く表3の判断。読めば分かる
last_query を貼って「なぜ0件?」向く展開後のSQLがあれば原因が見える
「wpdbの使い方を教えて」向かない%s と %d しか出てこない。%i は2023年の追加
「LIKE検索を書いて」向かない'%%%s%%' という古い形が返る。esc_like() を使う
本番データへの query() 実行向かないUPDATE・DELETEは取り返しがつかない。必ず自分で確認する

判定の目安です。出てきたコードに '%s'(引用符つき)があったら、情報が古いと思ってください。 %i が使われていれば、6.2以降を知っています。

よくある質問

プレースホルダは引用符で囲むのですか

囲みません。公式が「すべてのプレースホルダは引用符で囲まないままにしなければならない」と書いています(表4)。 prepare() が自分で付けるので、囲むと二重になります。

%i とは何ですか

テーブル名やカラム名のためのプレースホルダです。 WordPress 6.2 で追加されました(公式Changelog)。 それ以前は、識別子を安全に渡す方法がありませんでした。

%d に文字列を渡すとどうなりますか

エラーにならず 0 になります(表6)。 「0件返る」「IDが0のレコードを探している」という症状の原因は、だいたいこれです。

LIKE検索はどう書きますか

esc_like() でエスケープしてから前後に % を付け、 プレースホルダ自体は囲みません(詳しく)。 esc_like() を忘れると、入力された % がワイルドカードとして効きます。

insert() でも prepare() は必要ですか

不要です。insert() / update() / delete() / replace() は 配列で渡せばエスケープされます(表8)。 query() だけは必要です。

テーブル名はハードコードしていいですか

いけません。接頭辞は変更できます。 $wpdb->posts のようなプロパティか、自作テーブルなら $wpdb->prefix を使ってください(表9)。

エラーが出ずに0件返ります

$wpdb->last_query を見てください(表10)。 プレースホルダが展開されたあとのSQLが入っています。 $wpdb は失敗しても例外を投げないので、自分で取りに行く必要があります。

WP_Query と $wpdb、どちらを使うべきですか

投稿やユーザーを扱うなら WP_Query です(表3)。 $wpdb が要るのは、自作テーブルと、標準APIでは書けない集計クエリです。

まとめ

データベース側の文字コードの話は MySQLの「Incorrect string value」の直し方、 出力が混ざってJSONが壊れる話は Unexpected token '<' ... is not valid JSON の原因と直し方、 REST API経由で触る話は WordPress REST API の認証を通す に書きました。

プレースホルダの種類・バージョン・メソッドの挙動は、 2026年10月25日時点のWordPress公式リファレンスの記載によります。 手元のWordPressで実行しての確認は行っていません。 UPDATE や DELETE を試すときは、必ず本番ではない環境で、バックアップを取ってからにしてください。