2026年のDjangoとPostgreSQL: インデックス設計、全文検索、面接質問

Django と PostgreSQL の最適化を実践的に解説します。B-tree、部分インデックス、カバリングインデックス、SearchVector と GIN による全文検索、そして 2026 年の面接質問まで扱います。

インデックスと全文検索による Django と PostgreSQL の最適化

Django と PostgreSQL の最適化は、最初の 1000 ユーザーを超えて拡張できるアプリケーションと、一覧ビューを開くたびに動作が止まってしまうアプリケーションを分ける要素です。PostgreSQL には強力なインデックスエンジンと全文検索のサブシステムが備わっていますが、多くの Django プロジェクトはそれらを使わないまま放置し、無料で取り戻せるはずのクエリ性能を無駄にしています。本記事では、インデックス設計のパターン、django.contrib.postgres の検索ツール、そして 2026 年にミドルレベルとシニアの Django エンジニアを分けるデータベース面接の質問を取り上げます。

インデックスを追加する前に計測する

すべてのインデックスは読み取りを高速化しますが、その代わりに書き込みを遅くし、ディスク容量を消費します。実際のクエリに対して EXPLAIN (ANALYZE, BUFFERS) を実行し、インデックスを追加してから実行計画を比較してください。クエリプランナが一度も選ばないインデックスは、INSERT と UPDATE のたびにかかる純粋なオーバーヘッドにすぎません。

Django における PostgreSQL インデックスの基礎

PostgreSQL は既定では、主キーと unique=True を指定したカラムにのみ B-tree インデックスを作成します。それ以外のフィルタ対象やソート対象のカラムは、インデックスが存在するまでシーケンシャルスキャンで処理されます。Django は Meta.indexes を通じてインデックスを定義します。これは複合インデックス、部分インデックス、カバリングインデックスを一箇所でまとめて扱えるため、古い db_index=True よりも推奨されます。

複合インデックスにおけるカラムの順序は、見た目だけの問題ではありません。B-tree がクエリでインデックスを利用できるのは、フィルタがインデックス対象カラムの先頭からの連続した部分(先頭プレフィックス)を使う場合に限られます。この規則は Use The Index, Luke で詳しく解説されています。(status, created_at) に対するインデックスは WHERE status = ? ORDER BY created_at を処理できますが、同じインデックスでは created_at だけで絞り込むクエリを高速化できません。

python
# models.py
from django.db import models

class Order(models.Model):
    customer_email = models.EmailField()
    status = models.CharField(max_length=20)
    total = models.DecimalField(max_digits=10, decimal_places=2)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            # status に対する完全一致と範囲検索のための単一カラム B-tree
            models.Index(fields=["status"]),
            # 複合インデックス: WHERE status = ? ORDER BY created_at DESC を処理する
            # 利用するには先頭カラム (status) がフィルタに現れる必要がある
            models.Index(fields=["status", "-created_at"]),
        ]

この複合インデックスにより、「最新の保留中の注文」のようなダッシュボードのクエリは、テーブル全体をフィルタしてからソートするのではなく、単一のインデックススキャンで済むようになります。

特定のクエリに向けた部分インデックスとカバリングインデックス

部分インデックスは条件に一致する行だけを対象にするため、サイズが小さいままメモリに収まります。ほとんどの行が同じ値を共有し、クエリが常に少数派の行だけを参照する場合、部分インデックスは全体を対象とするインデックスよりも劇的に低コストです。カバリングインデックスはさらに一歩進みます。include でキー以外のカラムを追加すると、PostgreSQL はテーブルヒープに触れることなくインデックスだけでクエリに応答します。このアクセスパターンはインデックスオンリースキャンと呼ばれます。

python
# models.py
from django.db import models
from django.db.models import Q

class Order(models.Model):
    # フィールドは上記の定義と同じ

    class Meta:
        indexes = [
            # 部分インデックス: 完了済みの履歴を無視し、保留中の行のみをインデックス化する
            models.Index(
                fields=["created_at"],
                name="pending_orders_idx",
                condition=Q(status="pending"),
            ),
            # カバリングインデックス: total をインデックスから直接読み取る (インデックスオンリースキャン)
            models.Index(
                fields=["status"],
                include=["total"],
                name="status_total_covering_idx",
            ),
        ]

行の 2% が保留中である注文テーブルでは、部分インデックスは同じカラムに対する全体のインデックスの 50 分の 1 のサイズになることもあり、プランナはそれをバッファキャッシュ上に常駐させ続けます。include 句は PostgreSQL のインデックスオンリースキャン に記載されており、ステータスによる絞り込みの背後で数値カラムを集計する Django アプリケーションにおいて、最も見落とされがちな効果です。

PostgreSQL を使った Django の全文検索

title__icontains=query に手を伸ばすのは手軽に感じられますが、これはシーケンシャルスキャンを強制し、関連度で結果を並べ替えることができません。Django の全文検索は、SearchVectorSearchQuerySearchRank を通じて PostgreSQL ネイティブの tsvector 型と tsquery 型をラップします。検索ベクトルはテキストを字句(レキシム)に正規化し、ストップワードを取り除いて単語を語幹に還元します。そのため「running」で検索すると「run」や「ran」にもマッチします。

python
# views.py
from django.contrib.postgres.search import (
    SearchVector, SearchQuery, SearchRank,
)
from .models import Article

def search_articles(query_text):
    query = SearchQuery(query_text, config="english")
    # 重み A はタイトルの一致を本文の一致 (重み B) より上位にランク付けする
    vector = (
        SearchVector("title", weight="A")
        + SearchVector("body", weight="B")
    )
    return (
        Article.objects.annotate(rank=SearchRank(vector, query))
        .filter(rank__gte=0.1)
        .order_by("-rank")
    )

weight パラメータは、A(最上位)から D までの優先度のグループを割り当てます。両方に検索語が含まれる場合でも、タイトルの一致は本文の一致より上位にランク付けされます。これはユーザーが検索に期待する挙動と一致します。API の全体像と 4 段階の重み付けモデルは、Django の全文検索リファレンス で解説されています。

高速な検索のための GIN インデックスと SearchVectorField

上記のクエリはリクエストのたびに全行の検索ベクトルを再計算します。数千行なら問題ありませんが、100 万行では許容できません。本番向けのパターンでは、ベクトルを SearchVectorField に保存し、GIN インデックスでインデックス化します。GIN は、全文検索ドキュメントや JSONB のような複合値に対して PostgreSQL が用いるインデックスの種類です。

python
# models.py
from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex
from django.db import models

class Article(models.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()
    # 事前計算して保存した検索ドキュメント
    search_vector = SearchVectorField(null=True)

    class Meta:
        indexes = [
            # GIN は全文検索を対数時間のインデックススキャンに変える
            GinIndex(fields=["search_vector"]),
        ]

保存されたフィールドは title および body と同期を保つ必要があります。.update() でベクトルを書き込む post_save シグナルを使えば、QuerySet.update() が保存シグナルを発行しないため、シグナルの再帰的な発火を避けられます。

python
# signals.py
from django.contrib.postgres.search import SearchVector
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Article

@receiver(post_save, sender=Article)
def sync_search_vector(sender, instance, **kwargs):
    Article.objects.filter(pk=instance.pk).update(
        search_vector=SearchVector("title", weight="A")
        + SearchVector("body", weight="B"),
    )

Django 5.0 以降を使うチームであれば、データベース側で計算する GeneratedField や PostgreSQL のトリガを使うことで、カラムをデータベース内部で維持し、余分な UPDATE を完全になくせます。どの仕組みで値を維持するにせよ、クエリはインデックス化されたフィールドを直接フィルタするようになり、GIN インデックスは以前はテーブル全体のスキャンだったものを対数時間の検索に変えます。

GIN インデックスは書き込みコストが高い

GIN インデックスは読み取りを高速化しますが、各挿入が多数のインデックスエントリに触れるため、書き込みには実際のコストがかかります。書き込みが多いテーブルでは、fastupdate ストレージパラメータを設定し、ペンディングリストのサイズを監視してください。そうしないと、全文検索の書き込みがインデックスのメンテナンス処理の後ろで滞ってしまいます。

適切な PostgreSQL インデックスの種類を選ぶ

B-tree は適切な既定値ですが、PostgreSQL は django.contrib.postgres.indexes を通じて複数のインデックスの種類を提供しており、誤った種類を選ぶと、1 つのクエリも改善しないままディスクを浪費します。選択は、データの形とクエリが使う演算子に従います。これは PostgreSQL のインデックスの種類に関するドキュメント にまとめられています。

| インデックスの種類 | 最適な用途 | Django クラス | |------------|----------|--------------| | B-tree | スカラーカラムの完全一致と範囲検索 | models.Index | | GIN | 全文検索、JSONB、配列の包含 | GinIndex | | BRIN | あるカラムで並んだ巨大な追記専用テーブル | BrinIndex | | Hash | 大きなカラムに対する完全一致のみの検索 | HashIndex |

BRIN は時系列データにおいて特に注目に値します。タイムスタンプ順に挿入された数千万行のテーブルでは、created_at に対する BrinIndex は数キロバイトしか占有しませんが、B-tree なら数百メガバイトを必要とします。これは BRIN がブロック範囲ごとに最小値と最大値だけを保存するためです。トレードオフは、BRIN が有効なのは物理的な行の順序がインデックス対象カラムに追随している場合に限られる点で、これは追記専用のログやイベントテーブルでは自然に成り立ちます。

python
# models.py
from django.contrib.postgres.indexes import BrinIndex
from django.db import models

class Event(models.Model):
    payload = models.JSONField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            # 追記専用で時系列順のテーブルに対する範囲スキャン用の小さなインデックス
            BrinIndex(fields=["created_at"]),
        ]

インデックスの種類をアクセスパターンに合わせることは、シニア向けの面接で繰り返し登場するテーマです。なぜなら、それは候補者がどこにでも反射的に B-tree を追加するのではなく、ストレージのレイアウトについて考えられることを示すからです。

Djangoの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

2026 年の Django データベース面接の質問

データベースに関する質問がシニアの Django 面接を占めるのは、ORM を使いこなせることが、ORM が発行する内容を理解していることを意味するとは限らないからです。以下の質問は、2026 年に採用の面接官が実際に掘り下げる内容を反映しており、Django ORM クエリ最適化ガイド で扱ったクエリレベルのチューニングと自然に組み合わさります。

(a, b) に対する複合インデックスがクエリの役に立たないのはどのような場合か。 クエリが a で絞り込まない場合です。B-tree はまず先頭のカラムで並んでいるため、b だけのフィルタはインデックスを利用できません。これは最左プレフィックス(leftmost-prefix)の規則であり、これを説明できる候補者は、暗記したレシピではなくインデックスの構造を理解していることを示します。

select_relatedprefetch_related の違いは何か。 select_related は SQL の JOIN を実行し、外部キーと 1 対 1 のリレーションを単一のクエリで処理します。prefetch_related は 2 つ目のクエリを実行し、その結果を Python 側で結合します。これは多対多や逆方向の外部キーのリレーションに必要です。誤った方を選ぶと、最適化を逃すか、不要な 2 つ目のクエリを発生させることになります。

データベースのレベルで icontains は全文検索とどう違うか。 icontainsILIKE '%term%' にコンパイルされ、標準的な B-tree インデックスを利用できず、すべての行をスキャンします。全文検索は、GIN インデックスに支えられた事前計算済みの tsvector に対して照合し、結果を関連度で並べ替えます。この違いは、候補者がおもちゃのようなデータセットの段階を超えていることを示す信頼できるシグナルです。

大きなテーブルで count() が遅くなることがあるのはなぜか、そして代替手段は何か。 PostgreSQL はテーブルの行数をキャッシュしていないため、COUNT(*) は MVCC のもとで可視なすべての行をスキャンします。おおよその件数でよければ、pg_class.reltuples を参照すると推定値が即座に得られます。ページネーションであれば、インデックス化されたカラムに対するキーセットページネーションを使うことで、そもそも件数を数える処理を回避できます。これらのパターンの体系的な練習は、キャッシュを利用したカウンタが繰り返し登場する Django キャッシュ面接モジュール や、Django 学習トラック 全体で行えます。

EXPLAIN ANALYZEEXPLAIN 単体では分からない何を明らかにするか。 EXPLAIN はプランナが見積もったコストを表示するだけですが、EXPLAIN ANALYZE は実際にクエリを実行し、本当の所要時間と行数を報告します。見積もりと実際の行数の間に大きな差があるのは、テーブルの統計情報が古くなっていることを示し、ANALYZE で修正できます。これは、明らかに適用されるはずのインデックスをプランナが無視してしまう、よくある根本原因です。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

まとめ

  • 複合インデックス、部分インデックス、カバリングインデックスを監査しやすい一箇所にまとめるため、db_index=True ではなく Meta.indexes でインデックスを定義します。
  • 複合インデックスのカラムは最左プレフィックスの規則に従って並べます。インデックスが適用されるには、先頭のカラムがクエリのフィルタに現れる必要があります。
  • 完了済みの履歴が大半を占めるテーブルの保留中の注文のように、クエリが常に少数派の行だけを参照する場合は部分インデックスを使います。
  • include を使ってカバリングインデックスを追加し、読み取りの多い集計クエリでインデックスオンリースキャンを有効にしてヒープへのアクセスを省きます。
  • icontains による検索を SearchVectorSearchQuerySearchRank に置き換え、関連度によるランク付けと語幹処理を追加コストなしで手に入れます。
  • 全文検索が本番規模に達する前に、検索ドキュメントを GinIndex に支えられた SearchVectorField に保存し、シグナル、GeneratedField、またはデータベーストリガで同期を保ちます。
  • インデックスの判断は常に EXPLAIN (ANALYZE, BUFFERS) で検証します。プランナが一度も選ばないインデックスは、読み取り時の利点がないまま書き込み時に税金を課すだけのものです。

タグ

#django
#postgresql
#database optimization
#full text search
#python

共有

関連記事