開発

django-filterで一覧検索を実装する|FilterSet設計とDRF指定の判断

django-filterで一覧検索を実装する|FilterSet設計とDRF指定の判断

一覧画面に検索条件を足す依頼は、どの案件でもほぼ同じ形で来ます。素直に書けば、ビューの中でrequest.GETを1つずつ読んでfilter()を積む分岐が育っていく領域。django-filterはこの層を引き受け、GETパラメータをDjangoフォームとQuerySetの両方へ同時に変換します。ただし、同じFilterSetをテンプレート一覧に置くか、FilterViewに載せるか、DRFのバックエンドへ指定するかで、壊れた値を送られたときの応答は3通りに分かれる。この記事では26.1系を前提に、FilterSetの書き方から層ごとの挙動差、自前のクエリ組み立てとの線引きまでを実装の順で整理します。

まとめ:django-filter導入で先に決める4つの判断

手を動かす前に固めておくと後戻りが減るのは、次の4点です。第一に、どの層に置くか。素のFilterSetを関数ベースビューで使う構成、FilterViewに載せる構成、DRFのDjangoFilterBackendへ指定する構成の3つがあり、不正な検索条件への応答がそれぞれ「無視して全件」「0件」「400」に分かれます。層を移すと、宣言は同じまま画面の見え方だけが変わる部分。

第二に、フィルタ名をどう決めるか。Meta.fieldsに辞書形式を渡すとprice__ltのような名前が自動生成され、それがURLのクエリパラメータとして外部へ出ます。APIなら公開仕様そのもので、後から変えれば呼び出し側が壊れる箇所。第三に、文字列検索の既定をどう外すか。lookup_exprの既定はexactで、完全一致のままでは日本語の一覧検索はほぼ機能しません。

第四に、どこまでをパッケージに任せるか。条件が10個並ぶ検索フォームは任せたほうが短く収まりますが、条件同士が絡む集計や画面固有の結合は自前で組んだほうが読みやすい。線引きは最終章で言い切ります。なお本記事はDjango本体の前提をDjangoの特徴とMVTの入門解説に委ねます。

django-filterが引き受ける範囲と26.1系の前提

まず、このパッケージがコードのどこを置き換えるのかを固定します。範囲を誤解して入れると、ビューの分岐が減らないまま依存が1つ増えます。

FilterSetがGETパラメータをフォームとQuerySetへ変換する仕組み

FilterSetは2つの成果物を同時に生み出します。1つはQuerySet、もう1つはDjangoフォームです。request.GETと絞り込み対象のQuerySetを渡してインスタンス化すると、qs属性から絞り込み済みのQuerySetが取れ、form属性からは通常のDjangoフォームが取れます。つまり検索フォームのHTMLとクエリの組み立てが、1つのクラス定義から出てきます。

副作用もあります。検索欄の項目とクエリの条件が同じ宣言に束ねられるため、片方だけを変えられません。フォームに出さずクエリだけ効かせたい条件は、FilterSetへ渡す前のQuerySet側で絞る設計になります。権限による絞り込みはここへ寄せるのが定石で、呼び出し側からProduct.objects.filter(tenant=request.user.tenant)で絞ったQuerySetを渡してください。

26.1系が対応するDjangoとPythonのバージョン(2026年8月時点)

2026年8月時点の最新は26.1系で、2026年7月11日にリリースされています。番号が製品の世代を表していないため初見では戸惑う部分。公式は2部構成のCalVerを採用すると明記しており、前半が年、後半がその年内のリリース番号です。26.1は「2026年の1回目」であり、25.2からの飛びはメジャー更新ではありません。

PyPIのメタデータでは、Python要件が3.10以上、対応Djangoとして宣言されているのは5.2・6.0・6.1の3系列です。長期サポートのDjango 5.2で動く既存案件なら、26.1系をそのまま入れて構いません。宣言の外にある古いDjangoへ入れるなら、パッケージ側を過去のリリースへ固定する判断になります。

FilterSetの書き方:Meta.fieldsの2形式と明示宣言

FilterSetの定義はMetaへの宣言と、フィルタの明示宣言の2通りを混ぜて書きます。どちらを使うかで、生成されるクエリパラメータ名と後からの変更コストが変わります。

Meta.fieldsのリスト形式と辞書形式で生成されるフィルタ名の違い

リスト形式は最短の書き方で、フィールド名をそのまま並べます。生成されるのは完全一致のフィルタで、パラメータ名はフィールド名と同じ。対して辞書形式は、フィールドごとに複数のルックアップを指定できます。

# filters.py
import django_filters
from .models import Product

class ProductFilter(django_filters.FilterSet):
    name = django_filters.CharFilter(
        field_name="name", lookup_expr="icontains", label="商品名"
    )

    class Meta:
        model = Product
        fields = {
            "price": ["lt", "gt"],
            "released_at": ["exact", "year__gt"],
        }

公式ドキュメントの例では、この辞書形式からprice__lt・price__gt・release_date・release_date__year__gtの4つが生成されます。ここで押さえておきたいのは、exactだけは暗黙の既定としてフィルタ名に付かない点。released_atの完全一致はreleased_at__exactではなくreleased_atという名前で出ます。

生成された名前は、URLのクエリパラメータとして外部へ出ます。社内向け画面なら影響は小さいものの、APIとして公開すれば?price__gt=1000が仕様の一部。命名を握りたいなら辞書形式に頼らず明示宣言し、属性名でパラメータ名を決めてください。

明示宣言でlookup_exprとmethodを指定する場面の見分け方

明示宣言に切り替える基準は2つです。1つはルックアップと表示名を握りたい場合。NumberFilter(field_name="price", lookup_expr="gte")と書けば、パラメータ名は属性名で決まり、labelでフォームの表示も指定できます。もう1つは、単純なルックアップに落ちない条件です。この場合はmethodに処理名を渡し、QuerySetの組み立てを自分で書きます。

既定引数も把握しておくと迷いが減ります。lookup_exprはexact、distinctとexcludeとrequiredはいずれも偽が既定。例外はMultipleChoiceFilterで、リレーションを跨いだ重複を抑えるためdistinctが真です。範囲はRangeFilterが下限のみ・上限のみに自動対応し、DateFromToRangeFilterが_afterと_beforeで範囲を受け取ります。

文字列検索が既定のexactのままでは一覧検索に使えない理由

導入直後に「検索しても何も出ない」と言われる原因のほとんどが、ここに集約されます。CharFilterのlookup_exprは既定でexact、つまり完全一致です。商品名の一部を入れて探す用途では、ユーザーが全文を正確に打ち込まないかぎりヒットしません。文字列の検索欄にはicontainsを明示してください。

全体の既定を変えるFILTERS_DEFAULT_LOOKUP_EXPRという設定もありますが、数値や日付にも影響します。既定は触らず文字列フィルタだけ明示宣言する運用のほうが事故は少ない。部分一致で足りるか全文検索へ寄せるかは接続先で変わり、この判断はDjangoとPostgreSQLの接続設定と固有フィールドの判断で整理しています。

一覧画面の検索条件をテンプレートのフォームと連動させる実装手順

次は画面側です。検索と絞り込みが画面の主役になる一覧では、フォームの描画・条件の保持・件数の表示が絡み合います。

FilterViewで組む構成と関数ベースビューでqsとformを扱う構成

もっとも短いのはFilterViewを使う構成で、URLの定義だけで一覧と検索フォームが立ち上がります。テンプレート側ではfilter.formを描画し、filter.qsをループします。

# urls.py
from django_filters.views import FilterView
from .models import Product

urlpatterns = [
    path("products/", FilterView.as_view(model=Product)),
]
<form method="get">
  {{ filter.form.as_p }}
  <button type="submit">検索</button>
</form>
{% for obj in filter.qs %}
  {{ obj.name }}
{% endfor %}

権限で絞ったQuerySetを渡す場合や集計値を併せて返す場合は、関数ベースビューのほうが素直に書けます。ProductFilter(request.GET, queryset=...)で組み、f.qsとf.formをコンテキストへ渡すだけ。FilterViewでもget_queryset()の差し替えで同じことはできますが、渡すQuerySetを明示したいなら関数ベースが読み手に伝わります。検索が機能の中心になる会員向け・事業者向け画面はこの層の設計が使い勝手を決める部分で、当社のポータルサイトシステム開発でも一覧と検索の設計から入ります。

ページ送りで検索条件が消える不具合とGETパラメータの引き継ぎ

django-filter自体の問題ではないものの、ほぼ必ず踏むのがこれ。ページネーションのリンクを?page=2だけで組むと検索条件が消え、2ページ目から全件に戻ります。request.GETのコピーからpageを除いた文字列を作り、テンプレート側でページリンクへ連結してください。テンプレートタグを1つ用意しておけば画面ごとの書き分けが要りません。検証は条件を入れた状態で2ページ目へ進み、件数表示が変わらないかを見ます。

選択肢を動的に出すフィルタが画面表示ごとにクエリを増やす注意点

AllValuesFilterはデータベース内の現在値を選択肢に使うため、実データに応じてプルダウンが自動で埋まります。手数は減る一方、画面を開くたびに選択肢を作るクエリが走る仕様。行数が育ったテーブルでは、この1本が一覧全体の応答を引っ張ります。

ModelChoiceFilterを手で組む場合はqueryset引数が必須で、callableを渡せばrequestで選択肢を絞れます。ただし公式はrequestがNoneになりうる点に注意を促しており、その分岐を書かなければ管理コマンドやテストで落ちる箇所。一覧が遅いときの調べ方はN+1問題の検出方法とORM別の対策の手順がそのまま使えます。

DRFでフィルタバックエンドを指定する場合のFilterSetの違い

APIでも同じFilterSetを使い回せます。ただしインポート経路と検証の扱いが変わるため、テンプレート側と同じ感覚で書くと挙動が合いません。

DjangoFilterBackendの登録とfilterset_classの使い分け

全体で使うならDEFAULT_FILTER_BACKENDSへ登録し、ビュー単位ならfilter_backendsで指定します。フィルタの内容を細かく決めるならfilterset_class、単純な完全一致で足りるならfilterset_fieldsにフィールド名を並べるだけで済みます。

# settings.py
REST_FRAMEWORK = {
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
    ],
}

# views.py
from django_filters import rest_framework as filters

class ProductListAPI(generics.ListAPIView):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    filterset_class = ProductFilter

この2つの併用は公式が明確にサポート外としています。filterset_fieldsで始めて後から条件が増えたときは、併記ではなくfilterset_classへ移してください。バックエンドの生成処理はget_filterset_class()やget_filterset_kwargs()を差し替えられるため、テナントごとに条件を変えるような要件はここへ寄せられます。DRF自体の位置づけはDjango REST Frameworkの使いどころと最小構成を先に読むと掴みやすい。

rest_framework側のFilterSetを継承する理由と真偽値の受け取り

DRFで使う場合はfrom django_filters import rest_framework as filtersでインポートします。同名のクラスが2系統ある理由はウィジェットと例外の扱いです。API向けのBooleanFilterはブラウザのフォーム用チェックボックスではなく、文字列で真偽を受け取るウィジェットを使います。テンプレート用のFilterSetをそのままAPIへ載せると、真偽値のパラメータが意図した形で解釈されません。

もう1つの差が例外です。DRF側では検証エラーがDRF相当のValidationErrorへ変換され、レスポンスのエラー形式に乗ります。素のFilterSetのままではDjangoのフォームエラーとして扱われ、APIのエラー表現が崩れる部分。同じ条件をAPIと画面の両方で出す案件は、共通の親FilterSetを1つ置き、DRF側だけ継承元を差し替えると重複を避けられます。

並び替えをOrderingFilterへ寄せるかDRF側に任せるかの判断

並び替えは2系統から選べます。django-filterのOrderingFilterを使うと、FilterSetの宣言の中に並び替えも収まります。DRFにも同名のフィルタがあるため、混同しないよう最初にどちらを使うかを決めてください。

class ProductFilter(filters.FilterSet):
    order = filters.OrderingFilter(
        fields=(("released_at", "date"), ("price", "price")),
        field_labels={"released_at": "発売日"},
    )

このfieldsはモデルフィールド名とパラメータ名のマッピングで、外部に出るのはパラメータ名側です。released_atという列名を隠したまま?order=dateで受けられるため、カラム名のリネームが公開仕様へ波及しません。並び替えが固定で絞り込みと独立に扱えるなら、DRF側のフィルタへ任せて宣言を分けるほうが見通しはよくなります。

不正な検索条件を受けた時の挙動が置いた層で3通りに分かれる仕様

ここが実装で最も刺さる部分です。同じFilterSetでも、日付欄に文字列が入ったような不正な条件が来たときの見え方が層ごとに変わります。ソースを実測して整理します。

素のFilterSetは不正な項目を黙って無視し条件なしの全件を返す

FilterSet.qsは、バインドされていればerrorsへ触って検証を走らせたうえでfilter_queryset()を呼びます。回されるのはform.cleaned_dataで、検証に落ちた項目はそこに入らないため条件として適用されません。

結果として、日付欄に不正な文字列を入れた検索は「日付条件なしの検索」として通り、全件が並びます。ユーザーからは検索が効いていないように見え、開発側のログにも何も残りません。関数ベースビューでf.qsを直に使う構成では、f.is_valid()を自分で確認し、エラーがあればf.form.errorsを画面へ出す処理を書き添えてください。

FilterViewはstrictが既定で真のため0件表示になる

FilterViewは違う判断をします。FilterMixinがstrict = Trueを既定で持ち、BaseFilterView.get()は「未バインド、または検証を通った、またはstrictが偽」のときだけfilterset.qsを採ります。いずれにも当てはまらなければ、入るのはfilterset.queryset.none()という空のQuerySet。

つまり不正な条件が1つ混ざるだけで、一覧は0件になります。全件が出るよりは異変に気づきやすい設計ですが、「該当なし」と「入力が壊れている」が画面上で同じ見え方になるのは不親切。テンプレートでfilter.form.errorsを描画し、0件表示との出し分けを入れておくべきところです。全件のほうが望ましいならstrict=Falseを渡して素のFilterSetと同じ挙動へ寄せられます。

DRFはraise_exceptionが既定で真のため400を返す

DRFのバックエンドは3つ目の判断をします。DjangoFilterBackendはraise_exception = Trueを既定で持ち、filter_queryset()の中でfilterset.is_valid()が偽ならtranslate_validation(filterset.errors)を投げます。呼び出し側には400が返り、どの項目が不正かはレスポンスのボディへ入る形。

3層の違いを1つの表にすると、どこを揃えるべきかが見えてきます。

置いた層 不正な条件が来たとき 既定値
素のFilterSet その項目を無視して全件 設定なし
FilterView 空のQuerySetで0件表示 strict は真
DRFバックエンド 400で検証エラーを返す raise_exception は真

不正入力時の挙動をどれに揃えるかを画面の種類ごとに決める手順

決め方は画面の性質で分かれます。APIは400のままが望ましい。呼び出し側がパラメータの誤りを即座に検出できるためで、緩めれば誤ったクエリが静かに全件を引く事故につながります。raise_exceptionを偽へ倒すのは、外部の古いクライアントを壊せない移行期間に限るべき措置です。

画面側は検索が主機能かどうかで変えます。検索が主機能の一覧はstrictを既定のままにし、エラーメッセージを必ず出す。一覧が付随物のダッシュボードはstrict=Falseで全件へ倒したほうが利用者の作業を止めません。どれを選んでも、エラーが画面に出ていなければ利用者からは「壊れている」としか見えません。

django-filterを入れる条件と自前クエリで通す条件

最後に線引きを言い切ります。外部パッケージを1つ増やす判断なので、元が取れるかどうかで決めます。

django-filterを入れて手間が減る3条件と減らない場面

入れて元が取れるのは次の3条件が揃う場合です。第一に、検索条件が3つ以上あり今後も増える見込みがあること。1つか2つで固定なら、ビューにifを2本書くほうが依存もドキュメントも増えません。第二に、フォームの描画も必要なこと。画面も作るなら、条件宣言からフォームが出てくる利点が効きます。

第三に、同じ条件をAPIと画面の両方で出すこと。FilterSetを共有して継承元だけ差し替えられるため、条件の二重管理が消えます。逆に条件が固定の管理用一覧では手間はほとんど減りません。管理者向けの検索なら、まず標準のadminで足りるかを確かめてください。判断の材料はDjango管理画面の設計とadmin標準の範囲にまとめています。

django-filterを外して自前のクエリで書くほうがよい場面

自前で書いたほうが読みやすくなる場面は3つあります。1つ目は条件同士が絡む検索。「Aが指定されたときだけBの既定値を変える」ような依存関係は、宣言型のFilterSetへ載せるとmethodだらけになります。2つ目は検索結果に集計を混ぜる画面。annotate()の順序が数字を変えるため、条件の適用順を自分で握るほうが安全です。

3つ目は結合を跨いだ重複が絡む検索。distinctの指定を1つ誤ると件数だけが静かに増えます。この設計はQuerySet側の理解が前提なので、Django ORMのクエリ設計と集計の順序を先に押さえてから決めるとよいでしょう。全部を自前へ寄せる必要はありません。単純な条件はFilterSetへ任せ、絡む条件だけQuerySet側で先に絞って渡す折衷が実務では収まります。

methodへ逃がす境界とQuerySet以外を返して落ちる書き方

methodへ逃がすときの注意も1つ。filter_queryset()にはassert isinstance(queryset, models.QuerySet)が入っており、自作の処理がQuerySet以外を返すとAssertionErrorで落ちます。リストへ変換してPython側で絞る書き方は通りません。values_list()で主キーを取ってからfilter(pk__in=...)へ戻す形にしてください。

境界の目安は、その処理が単一のQuerySet変換として表現できるかどうか。表現できるならmethod、複数のQuerySetを合成したり件数で分岐したりするならビューやサービス層へ出す処理です。methodが5本を超えたら、FilterSetの宣言を維持する意味が薄れている合図。

よくある質問

django-filterの導入で実装者から挙がりやすい質問を、公式ドキュメントとソースの記述に対応づけて整理します。

django-filterはDRFを使わないプロジェクトでも導入できますか?

できます。もともとDjangoのフォームとQuerySetを繋ぐパッケージで、DRF連携はdjango_filters.rest_frameworkという追加の層です。テンプレートで一覧を返す構成なら、FilterViewを使うか関数ベースビューでf.qsとf.formを渡すだけで動きます。テンプレートを使う場合はINSTALLED_APPSへdjango_filtersを加えてください。

filterset_fieldsとfilterset_classは併用できますか?

できません。公式ドキュメントが明確にサポート外と記しています。単純な完全一致だけならfilterset_fieldsで始めて構いませんが、部分一致や範囲の条件が増えた時点でfilterset_classへ移します。移行時は生成済みのパラメータ名を確認し、属性名を合わせておけば呼び出し側の変更が不要です。

検索条件が空のときに全件を返さない書き方はありますか?

2通りあります。フィルタ側でrequired=Trueを指定すると、その条件が無い検索は検証に落ちます。層ごとの挙動差がそのまま出るため、FilterViewなら0件、DRFなら400。もう1つは呼び出し側でQuerySetを制御する方法で、条件が空ならnone()か直近の期間に限定したQuerySetを渡してください。

日本語の部分一致検索はicontainsで足りますか?

短い商品名や氏名の絞り込みであれば足ります。一方、文中の語をまたぐ検索や表記の揺れを吸収したい要件では判定が届かず、データベース側の全文検索へ寄せる判断になります。数万行を超えるとicontainsはインデックスが効きにくいため、応答時間を測ってから決めてください。

一覧画面が遅いときはdjango-filterのどこを見ますか?

見る順序は3段階です。まず1画面あたりのクエリ本数を数え、同じSQLが繰り返されていれば関連先の取得側の問題で、フィルタは無関係。次に選択肢を動的に作るフィルタの有無を確認します。AllValuesFilterが並んでいれば、それぞれが選択肢用のクエリを出しています。最後にWHERE句を読み、icontainsや結合を跨いだ条件がインデックスを使えているかを確かめてください。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.09 テックブログ IDCFクラウド(IDCフロンティア)不正アクセス・ランサムウェア:影響先・復旧・データは戻るか
  2. 2025.11.04 テックブログ Runpodとは?料金・使い方・支払い方法とGPUクラウドの選び方【2026年10月】
  3. 2026.01.09 コラム 違背実験とは?読み方・意味と具体例、ガーフィンケル原典の数値とエスノメソドロジー
  4. 2026.10.09 テックブログ ニッスイのサイバー攻撃で日水物流の入出荷停止|委託先クラウド障害に荷主が備える手順

RELATED POSTS 関連記事

目次