# Supabase RLSのパフォーマンス最適化：遅いポリシーを EXPLAIN で測り、(select)ラップ・索引・TO・JWTで100倍速くする

> Supabase（PostgreSQL）の行レベルセキュリティ(RLS)は正しく書いても遅くなる。auth.uid()の(select)ラップでper-row評価をInitPlanに畳む、ポリシー列へ索引を張る、TOでロールを絞る、JOINを集合参照に書き換える、security definer関数とJWTクレームでルックアップを消す——公式ベンチマーク準拠の6つの最適化を、EXPLAIN ANALYZEの計測手順つきで実コード解説します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Supabase, RLS, PostgreSQL, パフォーマンス, B2B SaaS
- URL: https://tomodahinata.com/blog/supabase-rls-performance-optimization-select-wrap-index-guide
- カテゴリ: データベース・RLS
- 総合ガイド: https://tomodahinata.com/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## 要点

- RLSポリシーは全クエリに付く暗黙のWHEREなので、述語が遅いと全行に効く。最適化は推測ではなくEXPLAIN ANALYZEで重い述語を特定してから、効く順に当てる
- 最大の一手は auth.uid() を (select auth.uid()) で包むこと。相関のないスカラ副問い合わせはInitPlanとして一度だけ評価され、per-row呼び出しが消える（公式ベンチで179ms→9ms）
- ポリシーが参照する列（user_id・tenant_id）に索引を張り、TO authenticatedでロールを絞る。索引なしの全表走査と、anonにまで走るポリシー評価が消える
- ポリシー内のJOINは集合参照（team_id in (select ...)）に書き換え、複雑な認可は private スキーマの security definer 関数に逃がして(select)で包む。JWTのapp_metadataに権限を載せればルックアップ自体が消える
- 数値はSupabase公式の合成ベンチで環境依存。最適化後はpgTAPで許可/拒否が壊れていないことを必ず再検証する——速いが漏れるRLSは最悪の退行

---

最初に、いちばん大事なことを言い切ります。**RLS（行レベルセキュリティ）のポリシーは、テーブルへのあらゆるクエリに自動で付く「暗黙の `WHERE` 句」です**（[Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)）。だから述語（`USING` / `WITH CHECK` の中身）が遅ければ、その遅さは**そのテーブルを触る全クエリ・全行に等しく乗ります**。1本のポリシーの書き方が、アプリ全体のレイテンシを決めることがある——これがRLSのパフォーマンスを軽視できない理由です。

そして厄介なのは、**RLSは「正しく」書いても遅くなる**ことです。`auth.uid() = user_id` という一見なんでもない述語が、行数が増えた本番で突然100倍遅くなる。原因は認可ロジックの間違いではなく、**PostgreSQLがその述語を「1行ごとに」評価してしまう**という、実行計画の問題です。

この記事は、Supabase（PostgreSQL）公式が示す**6つのRLSパフォーマンス最適化**を、「なぜ効くのか」という実行計画レベルの理由と、`EXPLAIN ANALYZE` で**自分の環境で計測する手順**つきで解説します。題材は、私が一人で構築した[複数人同時編集のリアルタイム試合記録アプリ](/case-studies/realtime-sports-scoring-app)（Expo + Next.js + Supabase のモノレポ）で、**69テーブル全てにRLSを有効化し、約280本のポリシー**を本番運用している実装です。ポリシーがこの規模になると、書き方一つの差が体感速度に直結します。

> **この記事の立ち位置**：RLSを**どう設計するか**（マルチテナント分離・`tenant_id`・membership）は[Supabase RLS本番設計ガイド](/blog/supabase-rls-production-multi-tenancy-patterns)、`USING` と `WITH CHECK` の**正しさ**は[書き込みバイパスの記事](/blog/supabase-rls-with-check-using-write-bypass-guide)、退行を止める**テスト**は[pgTAPの記事](/blog/supabase-rls-testing-pgtap-policy-regression-guide)で扱っています。本記事はそれらと重複せず、**「正しく書けたRLSを、いかに速くするか」**だけに集中します。設計・正しさ・テストが前提にある上での、性能の話です。

> **honesty contract**：本文中のベンチマーク数値（179ms→9ms 等）は、**Supabase公式ドキュメントが示す合成テーブルでの計測値**です。あなたの行数・分布・ハードウェア・インデックス状況では**再現しない**可能性が高い。数値は「効果のオーダー（桁）」の目安として読み、必ず後述の `EXPLAIN ANALYZE` で**自分のデータで計測**してください。仕様は[Supabase公式](https://supabase.com/docs/guides/database/postgres/row-level-security)・[PostgreSQL公式](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)（いずれも2026年6月時点）に基づきます。

---

## 0. なぜRLSは遅くなるのか：3行で言うと

最適化の前に、遅さの正体を3つに分解します。これが分かれば、6つの対策が「バラバラの小技」ではなく「3つの病に対する処方」だと見えてきます。

| 遅さの正体 | 何が起きているか | 効く対策 |
| --- | --- | --- |
| **per-row 関数評価** | `auth.uid()` 等が行ごとに呼ばれる | §2 `(select)`ラップ |
| **索引の不在** | ポリシー述語がフルスキャンを誘発 | §3 索引・§5 JOIN削減・§6 関数化 |
| **無駄なポリシー評価** | 関係ないロールにまで述語が走る | §4 `TO` 指定・§7 JWT |

PostgreSQL公式は、ポリシー式の評価順序をこう定めています——**「この式は、ユーザーのクエリ由来の条件や関数より*前に*、各行に対して評価される」**（[PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)）。つまりRLSの述語は**最も内側のホットパス**で回る。だからここが遅いと、他のどんな最適化も効きません。例外は `leakproof`（情報を漏らさないと保証された）関数だけで、その場合のみオプティマイザがRLSチェックより先にユーザー条件を適用できます。

ポイントは順序です。**まず計測（§1）、次に効果の大きい順（§2→§7）。** 推測で索引を乱立させるのは、RLSでも普通のクエリチューニングでも悪手です。

---

## 1. まず計測する：EXPLAIN ANALYZE と pg_stat_statements

「RLSが遅い」と言う前に、**どのポリシーの、どの述語が、何msか**を数字で掴みます。RLSは透過的に効くので、普通に `EXPLAIN ANALYZE` を流せば、ポリシーは実行計画の中に**フィルタ条件として現れます**。

### 1-1. 認証済みユーザーになりきって計測する

RLSは「誰として実行するか」で結果が変わります。`service_role`（RLSバイパス）で測っても無意味です。**`authenticated` ロールになりすまし、`request.jwt.claims` に `sub`（ユーザーID）を入れて**から計測します。

```sql
-- セッションを「特定の認証済みユーザー」に偽装して計測する
begin;
  -- このトランザクションだけ authenticated ロールで実行する
  set local role authenticated;
  -- auth.uid() が返すユーザーIDを注入する（本番のJWTと同じ経路）
  set local request.jwt.claims = '{"sub":"00000000-0000-0000-0000-000000000001","role":"authenticated"}';

  explain (analyze, buffers, verbose)
  select * from public.matches where game_id = '...';
rollback; -- 計測は副作用を残さない（冪等）
```

`rollback` で閉じるのは、計測を**何度流しても状態を変えない**ためです（冪等性）。実行計画に `Filter: (matches.user_id = auth.uid())` のような行が出て、その下の `Rows Removed by Filter` と実時間を見れば、ポリシーがどれだけ仕事をしているかが分かります。

### 1-2. 「per-row評価」を実行計画で見抜く

最適化前後で何が変わるのかを、計画の言葉で押さえます。

- **遅い形**：述語が `Filter` として全行に適用され、`auth.uid()` が各行で呼ばれる。`Seq Scan` ＋大きな `Rows Removed by Filter`。
- **速い形**：`(select auth.uid())` が `InitPlan 1` として計画の先頭に一度だけ現れ、本体は `Index Cond: (user_id = $0)` のように**索引で引ける**。

つまり目標は明確です——**述語を `Filter`（行ごと）から `Index Cond`（索引一発）に移す**こと。以下の6つは、すべてそのための手段です。

### 1-3. 本番では pg_stat_statements で「効いている遅さ」を探す

単発の `EXPLAIN` は仮説検証用です。本番で「どのクエリの累積コストが大きいか」は `pg_stat_statements` で探します(Supabaseはデフォルト有効)。

```sql
select calls, mean_exec_time, total_exec_time, query
from pg_stat_statements
order by total_exec_time desc
limit 20;
```

`mean_exec_time × calls = total_exec_time` が大きいクエリが、最適化のROIが最も高い対象です。**「1回が遅いクエリ」より「そこそこ遅いが大量に呼ばれるクエリ」**を先に直す——これはRLSに限らないチューニングの鉄則です。

---

## 2. 最強の一手：`auth.uid()` を `(select auth.uid())` で包む

効果が最も大きく、コストが最も低いのがこれです。**ポリシー内の関数呼び出しを、相関のないスカラ副問い合わせ `(select ...)` で包む**だけ。

```sql
-- ❌ 遅い：auth.uid() が行ごとに評価される
create policy "rls_select" on public.test_table
  for select to authenticated
  using ( auth.uid() = user_id );

-- ✅ 速い：(select auth.uid()) は一度だけ評価され、InitPlanに畳まれる
create policy "rls_select" on public.test_table
  for select to authenticated
  using ( (select auth.uid()) = user_id );
```

公式ベンチマークでは、この1文字レベルの違いで **179ms → 9ms（約95%改善）**。`is_admin()` のような重い関数を含む例では **11,000ms → 7ms**、security definer関数を絡めた例では **178,000ms → 12ms** という桁違いの差が報告されています（[Supabase: RLS Performance](https://supabase.com/docs/guides/database/postgres/row-level-security)）。

### なぜ効くのか：InitPlan という「定数畳み込み」

`auth.uid()` は、実体としては `current_setting('request.jwt.claims')` からユーザーIDを読む関数です。その値は、**1つのクエリ実行の間ずっと同じ**——行ごとに変わりません。

ところがPostgreSQLは、ポリシー述語に裸で `auth.uid()` と書かれていると、これを各行で律儀に呼び直します。**`(select auth.uid())` と副問い合わせで包むと**、PostgreSQLはこれを外側の行と相関のない式（uncorrelated subquery）と認識し、**`InitPlan` として実行の最初に一度だけ評価して結果を定数のように再利用**します。実行計画では `InitPlan 1 (returns $0)` として先頭に現れ、本体は `$0`（評価済みの定数）を参照するだけになります。

これは「キャッシュ」というより**定数畳み込み**です。だから副作用として、`user_id` に索引があれば `user_id = $0` を**索引で引ける**ようになり、§3の効果まで引き出せます。

> **適用条件（重要）**：この最適化が正しいのは、**包む式の結果が全行で同じ**ときだけです。`auth.uid()`・`auth.jwt()`・現在ユーザーに依存する関数はOK。逆に、`row.column` のように**その行に依存する値を含む式を `(select ...)` で包んではいけません**——意味が変わります。「ユーザー単位で一定の値」だけを畳む、と覚えてください。

---

## 3. ポリシーが参照する列に索引を張る

RLSポリシーは暗黙の `WHERE` です。ならば**普通のクエリと同じく、絞り込みに使う列には索引が要ります**。`user_id = (select auth.uid())` で絞るなら、`user_id` に索引を張る。

```sql
create index if not exists idx_test_table_user_id
  on public.test_table using btree (user_id);
```

公式ベンチでは **171ms → 0.1ms未満（約99.9%改善）**。§2で述語が `Index Cond` に乗れる形になった上で索引があると、**索引一発で引ける**ので劇的に速くなります。

実務での索引設計の指針：

- **単一テナント所有**：`user_id` に btree 単一列索引。
- **マルチテナント**：`tenant_id` が全ポリシーの共通述語になるので、`tenant_id` を**複合索引の先頭**に置く（例：`(tenant_id, created_at)`）。クエリの `order by created_at` まで索引で賄える。
- **membershipテーブル**（§5で多用）：`(user_id, team_id)` の複合索引と、逆引き用の `(team_id)` 索引。

> 索引は万能ではありません。書き込みが多くカーディナリティの低い列（例：`is_active` のような真偽値）への索引は、更新コストに見合わないことがあります。**必ず §1 の `EXPLAIN ANALYZE` で「`Index Scan` に変わったか」を確認**してから残してください。PostgreSQL本体の索引設計の詳細は『PostgreSQL本体・性能設計』クラスタに譲ります。

---

## 4. `TO` でロールを絞り、無駄なポリシー評価を消す

ポリシーに `TO authenticated` を書かないと、そのポリシーは **`anon`（未認証）を含む全ロールに対して評価されます**。未認証ユーザーのリクエストでも `auth.uid()` 比較が走り、無駄に遅くなる。

```sql
-- ❌ TO なし：anon のリクエストでもこの述語が評価される
create policy "rls_select" on public.rls_test
  using ( (select auth.uid()) = user_id );

-- ✅ TO authenticated：認証済みにだけ評価され、anon は即スキップ
create policy "rls_select" on public.rls_test
  for select to authenticated
  using ( (select auth.uid()) = user_id );
```

公式ベンチでは、`anon` がアクセスするケースで **170ms → 0.1ms未満（約99.8%改善）**。`anon` には端からこのポリシーが適用されないので、述語評価そのものが消えます。

これは**パフォーマンスと正しさが一致する**良いケースです。RLSの設計原則として、「誰として実行されるか（`anon` / `authenticated` / `service_role`）」を**全ポリシーで明示する**のは、[本番設計ガイド](/blog/supabase-rls-production-multi-tenancy-patterns)でも推奨した型です。`TO` を書く癖は、速さと安全の両方に効きます。

---

## 5. ポリシー内の JOIN を「集合参照」に書き換える

複雑な認可——「このユーザーが、この行の属するチームのメンバーか？」——を、ポリシー内で**テーブル結合（JOIN）**として書くと、その結合が**対象テーブルの行ごと**に走り、急激に遅くなります。

```sql
-- ❌ 遅い：source(対象表) と target(team_user) を結合している
create policy "rls_select" on public.test_table
  for select to authenticated
  using (
    (select auth.uid()) in (
      select user_id from team_user
      where team_user.team_id = test_table.team_id  -- 外側の行に相関＝行ごとに実行
    )
  );

-- ✅ 速い：先に「自分が所属するteam_idの集合」を作り、行はそれに含まれるか確認するだけ
create policy "rls_select" on public.test_table
  for select to authenticated
  using (
    team_id in (
      select team_id from team_user
      where user_id = (select auth.uid())  -- 外側に相関しない＝一度だけ実行
    )
  );
```

公式ベンチでは **9,000ms → 20ms（約99.8%改善）**。

### なぜ効くのか：相関を断ち切る

最初の形は、副問い合わせが `test_table.team_id` を参照しているため**外側の行に相関**し、行ごとに `team_user` を引き直します（N回のルックアップ）。

後者は、副問い合わせが `(select auth.uid())` にしか依存しないため**外側と相関せず、一度だけ実行**されて「自分の所属チームID集合」を作ります。あとは各行の `team_id` がその集合に入るかを `in` で確認するだけ。`O(N回のJOIN)` が `O(1回の集合構築 + N回のメンバ判定)` に変わります。**「対象表と認可表をJOINする」のではなく「認可データを集合として先に引く」**——これがRLSにおける最重要のリライトパターンです。

---

## 6. 複雑な認可は `security definer` 関数に逃がす

`in (select ...)` でも表現しきれない多段の認可（役割・期限付き権限・項目別開示など）は、**`security definer` 関数**に閉じ込めます。この関数は呼び出し元の権限ではなく**関数所有者の権限で実行され、内部でRLSをバイパス**するので、ポリシーの述語評価がそこで完結します。

```sql
-- 認可ロジックを private スキーマ（API非公開）の関数に閉じ込める
create or replace function private.has_team_access(target_team_id uuid)
returns boolean
language sql
security definer
set search_path = ''            -- search_path注入を塞ぐ（必須）
stable                          -- 同一トランザクション内で結果が変わらないと明示
as $$
  select exists (
    select 1 from public.team_user
    where user_id = (select auth.uid())
      and team_id = target_team_id
  );
$$;

revoke execute on function private.has_team_access(uuid) from anon, authenticated;
grant  execute on function private.has_team_access(uuid) to authenticated;

-- ポリシーからは (select ...) で包んで呼ぶ（§2の最適化を効かせる）
create policy "rls_select" on public.test_table
  for select to authenticated
  using ( (select private.has_team_access(team_id)) );
```

公式ベンチでは、security definer 関数を `(select ...)` で包んだケースで **178,000ms → 12ms** という極端な改善が報告されています。

ただし `security definer` は**RLSをバイパスする刃物**でもあります。3点を必ず守ってください。

1. **公開スキーマに置かない**。Supabase公式が明言する通り、security definer関数を「Exposed schemas（API設定で公開されたスキーマ）」に作ってはいけません。`private` など非公開スキーマに置きます。
2. **`set search_path = ''`** を必ず付け、関数内のオブジェクトは `public.team_user` のように**完全修飾**する。これを怠ると、悪意ある `search_path` で別オブジェクトを掴まされる注入経路になります。
3. **最小権限**。`anon` から `execute` を剥がし、必要なロールにだけ付与する。

この関数を**RLSをすり抜ける穴**にしないための設計と、`search_path` の落とし穴は、[security definer 関数とsearch_pathの記事](/blog/supabase-security-definer-function-search-path-guide)で詳述しています。性能のために導入した関数が認可の穴になっては本末転倒なので、必ず併読してください。

---

## 7. 一段上の最適化：JWTクレームでルックアップ自体を消す

ここまでは「DBへの問い合わせを速くする」話でした。最後は発想を変えます——**そもそもDBを引かない**。

ユーザーの所属チームや役割が頻繁に変わらないなら、それを**JWTの `app_metadata` に載せて**しまえば、ポリシーは**DBを一切引かずにトークンの中身だけで判定**できます。`team_user` テーブルへのルックアップ（§5・§6）すら消えます。

```sql
-- JWTのapp_metadataに格納された teams 配列で判定する（DBルックアップなし）
create policy "user is in team" on public.my_table
  for select to authenticated
  using (
    team_id in (
      select jsonb_array_elements_text(
        (select auth.jwt() -> 'app_metadata' -> 'teams')
      )::uuid
    )
  );
```

重要な注意が2つあります。

- **`app_metadata` を使う（`user_metadata` ではなく）**。[Supabase公式](https://supabase.com/docs/guides/database/postgres/row-level-security)は、`raw_user_metadata` は**ユーザー自身が書き換え可能**なので**認可に使ってはいけない**、認可には**書き換え不可の `raw_app_metadata`** を使え、と明記しています。ここを間違えると、ユーザーが自分の権限を昇格できる致命的な穴になります。
- **トークンの鮮度とのトレードオフ**。JWTは発行時点のスナップショットです。チームから外れても、トークンが失効するまで（リフレッシュまで）古い権限が残ります。**即時失効が要る権限はDBルックアップ（§5/§6）、変化が緩い権限はJWT**、と使い分けます。これは性能と一貫性の古典的なトレードオフです。

同じく、MFA（多要素認証）の強度をRLSで要求するのもJWTクレームの応用です。`restrictive`（AND結合）ポリシーで「`aal2` でなければ更新不可」を全経路に強制できます。

```sql
-- 二要素認証を通したセッションでなければ更新を拒否する（全UPDATEにANDで効く）
create policy "require mfa for updates" on public.profiles
  as restrictive            -- 既存のpermissiveポリシーとANDで結合される
  for update to authenticated
  using ( (select auth.jwt()->>'aal') = 'aal2' );
```

`restrictive` ポリシーは、他の `permissive` ポリシー（OR結合）と**AND**で結合されます（[PostgreSQL公式](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)）。「許可を足す」のがpermissive、「全体に制約をかける」のがrestrictive——テナント境界やMFA要求のような**絶対に外せない条件**はrestrictiveで固定します。

---

## 8. 本番投入の順番：効く順に当て、テストで守る

6つ（+JWT）の最適化を、ROIの高い順に並べると次の通りです。上から順に当て、その都度 §1 の `EXPLAIN ANALYZE` で効果を確認するのが最短経路です。

| 優先 | 最適化 | コスト | 効果（公式ベンチの桁） | いつ効くか |
| --- | --- | --- | --- | --- |
| 1 | `(select auth.uid())` ラップ | 極小 | 〜100倍 | ほぼ全ポリシー |
| 2 | ポリシー列に索引 | 小 | 〜1000倍 | 行数が多い表 |
| 3 | `TO authenticated` 明示 | 極小 | anon経路で〜1000倍 | 公開＋認証混在 |
| 4 | JOIN→集合参照のリライト | 中 | 〜数百倍 | membership認可 |
| 5 | security definer関数化 | 中 | 多段認可で桁違い | 複雑な認可 |
| 6 | JWTクレーム化 | 中 | ルックアップ消滅 | 変化の緩い権限 |

そして、**最後に必ずやること**があります。

> **速くなったRLSが、まだ正しいか？を再検証する。**

パフォーマンス最適化は述語を書き換える行為です。`(select)` ラップ・JOINリライト・関数化は、**うっかり認可の意味を変える**リスクを常に伴います。「速いが、他人の行が見える」RLSは、遅いRLSより遥かに危険です。だから最適化の前後で、[pgTAPの記事](/blog/supabase-rls-testing-pgtap-policy-regression-guide)で示した**「許可されるべきが許可される」「拒否されるべきが拒否される」の両方**を自動テストで回し、CIで退行を止めてください。**性能と正しさは、片方だけでは本番に出せません。**

私の[リアルタイム試合記録アプリ](/case-studies/realtime-sports-scoring-app)では、280本のポリシー全てにこの6つを設計段階から織り込み、pgTAPで許可/拒否を検証した上で、複数人が同時に同じデータを別の見え方で触る画面を実用的なレイテンシで動かしています。RLSは「書いて終わり」でも「速くして終わり」でもなく、**設計 → 性能 → 検証**の3点セットで初めて本番に耐えます。

---

## まとめ：RLSの遅さは「実行計画の病」であって認可の間違いではない

- RLSポリシーは**全クエリに付く暗黙のWHERE**。述語が遅ければ全行に効くので、性能は設計の一部です。
- 遅さの正体は3つ——**per-row関数評価・索引の不在・無駄なポリシー評価**。6つの最適化はこの3つへの処方です。
- 最大の一手は **`(select auth.uid())` ラップ**。相関のないスカラ副問い合わせがInitPlanに畳まれ、per-row評価が消え、索引にも乗る。
- **索引・`TO`明示・JOINの集合参照化・security definer関数・JWTクレーム化**を、`EXPLAIN ANALYZE` で計測しながら効く順に当てる。
- 数値は環境依存。**最適化後はpgTAPで許可/拒否を再検証**し、「速いが漏れる」退行を絶対に出さない。

### 一次情報（必ず最新を確認してください）

- [Supabase: Row Level Security（パフォーマンス節を含む）](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- [PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)
- [PostgreSQL: EXPLAIN](https://www.postgresql.org/docs/current/sql-explain.html)
