WordPressの $wpdb で直接SQLを書く — プレースホルダは4種類あり、引用符で囲んではいけない
先に結論
$wpdb->prepare() のプレースホルダを %s と %d の2つだと思っているなら、
情報が2023年より前のものです。いまは4種類あります。
| 記号 | 意味 | 追加 |
|---|---|---|
%d | integer(整数) | 最初から |
%s | string(文字列) | 最初から |
%f | float(小数) | 最初から |
%i | identifier(テーブル名・カラム名) | 6.2.0 |
そして、書き方に強い決まりがあります。
「すべてのプレースホルダは、クエリ文字列の中で引用符で囲まないままにしなければなりません。
各プレースホルダには、対応する引数を必ず渡さなければなりません」
— wpdb::prepare()(WordPress 公式リファレンス・要約)
'%s' のようにシングルクォートで囲む書き方は間違いです。
prepare() が自分で引用符を付けるので、二重に囲むことになります。
| 書き方 | |
|---|---|
| 正 | WHERE post_title = %s |
| 誤 | WHERE post_title = '%s' |
| 正 | FROM %i(6.2以降) |
| 誤 | FROM `%s` |
そもそも $wpdb を使うべきか
最初に確認してください。 投稿やユーザーを扱いたいだけなら、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() の書き方
プレースホルダは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(しなければならない)」が使われています。
| 書き方 | 結果 |
|---|---|
= %s | prepare() が引用符を付ける。正しい |
= '%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 が使える
}
| 状況 | 書き方 |
|---|---|
| 固定のテーブル名 | {$wpdb->posts} を埋め込む。これで十分 |
| 変数で決まるカラム名 | %i を使う(6.2以降) |
| 6.1以前もサポートする | has_cap() で分岐するか、許可リストで検証する |
型が合わないと値が変わる
%d に文字列を渡してもエラーにはなりません。
変換されて別の値になります。公式リファレンスが具体例を載せています。
| 渡した値 | 結果 |
|---|---|
1 | "1" |
1.1 | "1"(小数が落ちる) |
'string' | "0" |
NULL | "0" |
false | "0" |
3行目が怖いところです。
文字列を %d に渡すと 0 になります。
「0件返る」「IDが0のレコードを探している」という症状は、だいたいこれです。
小数を扱うなら %f、NULLを入れたいなら prepare() では表現できないので、
クエリ側で IS NULL を書きます。
取得メソッドの使い分け
欲しい形に合わせて選びます。どれも同じSQLを渡せますが、返ってくる形が違います。
| メソッド | 返るもの | 使う場面 |
|---|---|---|
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() は、配列で渡せばエスケープもやってくれます。
| メソッド | 役割 | 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_ ですが、変えられるのでハードコードしてはいけません。
| 書き方 | 指すもの |
|---|---|
$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 や空配列が返るだけなので、原因を自分で取りに行きます。
| 順 | 見るもの | 分かること |
|---|---|---|
| 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に任せるときの注意
| 頼むこと | 向くか | 理由 |
|---|---|---|
| 生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では書けない集計クエリです。
まとめ
- プレースホルダは4種類。
%d%s%fと、6.2で追加された%i。 - プレースホルダを引用符で囲まない。公式が「MUST」で書いている。
'%s'は間違い。 %iでテーブル名・カラム名を安全に渡せる。対応はhas_cap('identifier_placeholders')で確認。%dに文字列を渡すとエラーにならず0になる。0件返る症状の典型。- LIKEは
esc_like()を通す。忘れると入力の%がワイルドカードになる。 - 取得は形で選ぶ。件数なら
get_var()、1列ならget_col()。 insert()系にprepare()は不要。必要なのはquery()だけ。- 書式の第3引数は省略せずに書く。省略すると全部
%sになる。 - テーブル名はハードコードしない。マルチサイトでは
prefixとbase_prefixを使い分ける。 - 失敗しても例外は出ない。詰まったら
last_query→last_errorの順に見る。 - そもそも
WP_Queryで足りないか先に確認する。投稿を扱うだけなら SQL は要らない。
データベース側の文字コードの話は MySQLの「Incorrect string value」の直し方、 出力が混ざってJSONが壊れる話は Unexpected token '<' ... is not valid JSON の原因と直し方、 REST API経由で触る話は WordPress REST API の認証を通す に書きました。
プレースホルダの種類・バージョン・メソッドの挙動は、
2026年10月25日時点のWordPress公式リファレンスの記載によります。
手元のWordPressで実行しての確認は行っていません。
UPDATE や DELETE を試すときは、必ず本番ではない環境で、バックアップを取ってからにしてください。