Query Go
WHERE の左辺に関数や演算を書かない — SARGable なクエリ
チューニング

WHERE の左辺に関数や演算を書かない — SARGable なクエリ

WHERE 左辺の関数・演算がインデックスを無効化する仕組みと、SARGable な書き換えパターン。

SARGable とは — インデックスを使える書き方

SARGable は Search ARGument-able の略で、「そのまま検索引数としてインデックスに渡せる形になっている」条件のことです。B-Tree インデックスは列の値そのものがソートされているので、列自体で比較しないと索引を辿れません。

ルールはシンプルです:

  • NG: WHERE LOWER(col) = 'x' — 列に関数が掛かっている
  • NG: WHERE col + 1 = 100 — 列に演算が掛かっている
  • OK: WHERE col = 'x' — 列そのまま
  • OK: WHERE col = 100 - 1 — 右辺は何をしても良い(実行前に定数畳み込み)

RDBMS は関数の中身を理解しないので、LOWER(email) を「email の値と同じ順序で並んでいるはず」とは判断できません。結果としてインデックスを諦めてSeq Scan に落ちるのが典型的な遅さの原因です。

sql
-- NG: インデックスが使われない
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';

-- OK: インデックスが使われる
SELECT * FROM users WHERE email = 'alice@example.com';

-- OK: 右辺の演算は問題なし(実行前に定数化される)
SELECT * FROM users WHERE created_at >= NOW() - INTERVAL '7 day';

典型アンチパターン一覧

現場でよく出る NG パターンです。書き方 → 書き換えを覚えれば大半は防げます。

目的NG(インデックス不使用)OK(書き換え)
大小文字無視の検索WHERE LOWER(email) = 'x'保存時に小文字化 or 関数索引(下記)
数値の比較WHERE price + 100 > 1000WHERE price > 900
前方 N 文字一致WHERE SUBSTRING(code, 1, 3) = 'ABC'WHERE code LIKE 'ABC%'
特定日の抽出WHERE DATE(created_at) = '2026-04-17'WHERE created_at >= '2026-04-17' AND created_at < '2026-04-18'
月・年の抽出WHERE YEAR(created_at) = 2026WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'
文字列 → 数値WHERE CAST(id AS TEXT) = '42'WHERE id = 42
末尾空白無視WHERE TRIM(name) = 'Alice'保存時に TRIM 済みで入れる
NULL 置換WHERE COALESCE(status, 'draft') = 'draft'WHERE status IS NULL OR status = 'draft'

ポイントは範囲で置き換えること。「特定日の全行」も「2026 年の全行」も、不等号 2 つで切り取れます。これならインデックスの範囲スキャンで処理できます。

関数索引(式インデックス)— 書き換えられないときの逃げ道

アプリ要件上、左辺の関数をどうしても外せないこともあります(既存 API の後方互換、大文字小文字混在の歴史的データなど)。そのときは関数の結果にインデックスを貼るという逃げ道があります。

  • PostgreSQL: CREATE INDEX ... ON t (LOWER(col)) のように式インデックスを直接書ける。あるいは citext 型で最初から大文字小文字無視
  • MySQL 8.0+: 関数インデックスが正式サポート。CREATE INDEX ... ON t ((LOWER(col)))(二重カッコ必須)。それ以前は生成列にインデックス
  • SQL Server: 計算列(computed column)を作り、PERSISTED にしてインデックスを貼る
  • Oracle: ファンクションベースインデックスが昔から使える(CREATE INDEX ... ON t (LOWER(col))
  • SQLite: 式インデックス対応(CREATE INDEX ... ON t (LOWER(col))

ただし関数索引は書き込みコストが増える上、クエリ側の関数呼び出しと完全一致していないと使われません(引数違い・関数違いは別物扱い)。乱用せず、書き換えられるなら書き換えるのが原則です。

sql
-- PostgreSQL: 式インデックス
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';  -- 使われる

-- PostgreSQL: citext で型自体を大小無視に
CREATE EXTENSION citext;
ALTER TABLE users ALTER COLUMN email TYPE citext;
SELECT * FROM users WHERE email = 'Alice@Example.com';  -- そのまま大小無視

-- MySQL 8.0+: 関数インデックス(二重カッコ必須)
CREATE INDEX idx_users_email_lower ON users ((LOWER(email)));

-- SQL Server: 計算列 + PERSISTED + インデックス
ALTER TABLE users ADD email_lower AS LOWER(email) PERSISTED;
CREATE INDEX idx_users_email_lower ON users (email_lower);

-- Oracle: ファンクションベースインデックス
CREATE INDEX idx_users_email_lower ON users (LOWER(email));

LIKE の例外 — 前方一致は SARGable、後方・中間一致は NG

LIKE は「関数が掛かっている」ように見えますが、前方一致'abc%')は B-Tree の範囲検索にそのまま落とせるためSARGableです。一方、後方一致'%abc')や中間一致'%abc%')はインデックスが使えません。

パターンインデックス代替策
LIKE 'abc%'(前方)使われる(B-Tree の範囲スキャン)特になし
LIKE '%abc'(後方)使われない列を逆順保存してその列にインデックス、または全文検索
LIKE '%abc%'(中間)使われない全文検索(PostgreSQL FTS, MySQL FULLTEXT, Elasticsearch)やtrigram(PostgreSQL pg_trgm
正規表現 ~ / REGEXP原則使われないpg_trgm のような専用インデックスで部分対応

PostgreSQL なら pg_trgm 拡張 + GIN インデックスで中間一致も索引検索できます。MySQL は FULLTEXT インデックス、SQL Server はフルテキスト索引が本命。本格的な検索なら Elasticsearch / OpenSearch を前段に置く構成が無難です。

EXPLAIN で本当に使われているか確認

書き換えたあとは必ず EXPLAIN で検証してください。期待と実際が乖離することは珍しくありません。

  • PostgreSQL: Index Scan using ...Index Only Scan が出ていれば成功。Seq Scan + Filter: (lower(email) = 'x') の形が残っていれば失敗
  • MySQL: type=ref / range / eq_ref なら使われている。type=ALL は全件走査
  • SQL Server: 実行プランで「Index Seek」が出ていれば OK。「Index Scan」や「Clustered Index Scan」は事実上の全件走査
  • Oracle: INDEX RANGE SCAN / INDEX UNIQUE SCAN なら使われている。TABLE ACCESS FULL は全件走査
sql
-- PostgreSQL: 書き換え前後で EXPLAIN を比較
EXPLAIN ANALYZE SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';
-- Seq Scan on users  (cost=0.00..1234.00 rows=...)
--   Filter: (lower(email) = 'alice@example.com')

EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'alice@example.com';
-- Index Scan using idx_users_email on users  (cost=0.42..8.44 rows=1)
--   Index Cond: (email = 'alice@example.com')