Django DRF #3 Filtering / Ordering / Pagination

#2 で認証 / 権限の土台が固まったので、今回は 一覧取得を整える道具たち です。記事が 100 件なら全部送り返してもいいですが、10 万件 / 1 億件になる瞬間にページネーションとフィルタリングは必須になります。

DRF は 3 つの領域によくできたビルトインを持ちます。

  • Filtering?published=true&author=3 のような条件
  • Ordering?ordering=-created_at のようなソート
  • Pagination — 1 度に何件ずつ、次のページはどこ

filter_backends — 一カ所にまとめたフィルタリングパイプライン #

ListAPIView / ModelViewSetlist アクションは queryset を受け取って filter_backends を順に通した後にシリアライズします。バックエンドは 3 つが標準。

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

それぞれが別の領域を埋めます。

バックエンド動作クエリ例
DjangoFilterBackendフィールド別の同等 / 範囲フィルタ?author=3&published=true
SearchFilterテキスト検索 (icontains など)?search=django
OrderingFilterソート?ordering=-created_at

django-filter — フィールドフィルタリング #

インストール
uv add django-filter
settings.py
INSTALLED_APPS += ["django_filters"]

最もシンプル — filterset_fields #

blog/views.py
from django_filters.rest_framework import DjangoFilterBackend


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    filter_backends = [DjangoFilterBackend]
    filterset_fields = ["author", "published"]

これで終わり。?author=3&published=true のようなクエリが自動で同等比較フィルタとして動作します。

リクエスト
GET /api/posts/?author=3&published=true

FilterSet — より豊かな表現 #

同じフィールドでも gteltin のような lookup が必要なら FilterSet クラス で。

blog/filters.py
import django_filters
from .models import Post


class PostFilter(django_filters.FilterSet):
    title = django_filters.CharFilter(lookup_expr="icontains")
    created_after = django_filters.DateFilter(
        field_name="created_at", lookup_expr="gte"
    )
    created_before = django_filters.DateFilter(
        field_name="created_at", lookup_expr="lte"
    )
    author_in = django_filters.BaseInFilter(field_name="author")
    has_comments = django_filters.BooleanFilter(method="filter_has_comments")

    class Meta:
        model = Post
        fields = ["author", "published"]

    def filter_has_comments(self, queryset, name, value):
        if value:
            return queryset.filter(comments__isnull=False).distinct()
        return queryset.filter(comments__isnull=True)
blog/views.py
from .filters import PostFilter


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    filter_backends = [DjangoFilterBackend]
    filterset_class = PostFilter

クエリ例:

さまざまな条件
?title=django                       # タイトルに "django" を含む (icontains)
?created_after=2026-01-01           # それ以降
?created_before=2026-12-31          # それ以前
?author_in=1,2,3                    # author IN (1, 2, 3)
?has_comments=true                  # コメントがある記事だけ

Method filter — 任意のロジック #

method=... を使えば 任意の関数 がフィルタとして動作します (has_comments がその例)。単純な lookup では表現できない条件を解くのによいです。

OrderingFilter — ソート #

blog/views.py
from rest_framework.filters import OrderingFilter


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    filter_backends = [DjangoFilterBackend, OrderingFilter]
    filterset_class = PostFilter
    ordering_fields = ["created_at", "title"]
    ordering = ["-created_at"]   # デフォルトソート

ordering_fields許可リスト です。書いていないフィールドは無視されます — クライアントが ?ordering=secret_field のような危険なソートをできないように。

クエリ
?ordering=-created_at        # 降順
?ordering=title              # 昇順
?ordering=-published,title   # 複数ソート

ordering_fields = "__all__" も可能ですが、インデックスのないフィールドでソートすると大きなテーブルでは遅いです。明示的なホワイトリスト が安全です。

SearchFilter — テキスト検索 #

複数フィールドにまたがるシンプルな検索。

blog/views.py
from rest_framework.filters import SearchFilter


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    search_fields = ["title", "body", "author__username"]
クエリ
?search=django

search_fields の prefix で検索モードを指定できます。

prefix意味
(なし)icontains (部分一致、大文字小文字無視)
^istartswith (前方一致)
=iexact (完全一致)
@full-text search (Postgres のみ)
$regex
search_fields の例
search_fields = ["^title", "=email", "body"]

本物の検索は別の道具で #

SearchFilter は小さなデータで素早く始めるためのものです。本物の検索が必要なら:

  • PostgreSQL full-text search (SearchVectorSearchQuery)
  • OpenSearch / Elasticsearch / Meilisearch のような検索エンジン

上級 #3 クエリ最適化 で見たインデックス戦略と一緒に行ってこそ大きなデータで生き残れます。

Pagination — 3 つのページネーション #

DRF が標準で提供する 3 つの paginator。それぞれ位置が違います。

PageNumberPaginationLimitOffsetPaginationCursorPagination
クエリ?page=2?limit=20&offset=40?cursor=cD0xMjM=
メタcountnextpreviouscountnextpreviousnextprevious (no count)
大きなテーブル遅くなる (count + offset)非常に遅くなる (offset が深いほど)速い (インデックス使用)
ソート変更可能可能固定 ordering が必要
データ追加 / 削除ページぶれページぶれ安定
向く場面一般的な UI ページネーションクライアントが offset を直接調整無限スクロール、大きなテーブル

PageNumberPagination #

settings.py
REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}
リクエスト
GET /api/posts/?page=2
レスポンス
{
  "count": 1234,
  "next": "http://localhost:8000/api/posts/?page=3",
  "previous": "http://localhost:8000/api/posts/?page=1",
  "results": [...]
}

最も馴染みのある形 — 「1、2、3 … 62」のようなページネーション UI と自然に合います。

カスタム PageNumberPagination #

blog/pagination.py
from rest_framework.pagination import PageNumberPagination


class StandardPagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = "page_size"   # クライアントが調整
    max_page_size = 100                   # 1 ページの最大

page_size_query_param を置けば ?page_size=50 のような形でクライアントがページサイズを調整できます (悪意のある暴走を防ぐために max_page_size と一緒に)。

LimitOffsetPagination #

settings.py
REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
}
リクエスト
GET /api/posts/?limit=20&offset=40

ページ番号の代わりに offset で直接ジャンプできる形。SQL の LIMIT/OFFSET と 1:1。

CursorPagination — 大きなテーブルの安全な答え #

PageNumber/LimitOffset の両方とも offset が深いほど遅くなる 落とし穴があります。SQL が結果をそこまで全部数える必要があるからです。

🚫 深い offset
SELECT * FROM blog_post ORDER BY created_at DESC LIMIT 20 OFFSET 100000;

このクエリは 100,020 行を読んで最後の 20 だけを返します。ページが深いほど比例して遅くなり、その間に行が追加 / 削除されると同じ記事が 2 度見えたり抜けたりします (ページぶれ)。

CursorPaginationインデックスでジャンプ します。

✅ cursor
SELECT * FROM blog_post WHERE created_at < '2026-05-01 12:00:00' ORDER BY created_at DESC LIMIT 20;

最後に見た行の created_at を覚えて、「それより以前」で次のページを取得します。データサイズと無関係に一定時間

blog/pagination.py
from rest_framework.pagination import CursorPagination


class PostCursorPagination(CursorPagination):
    page_size = 20
    ordering = "-created_at"   # インデックスがあるフィールドであるべき
    cursor_query_param = "cursor"
blog/views.py
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    pagination_class = PostCursorPagination
リクエスト
GET /api/posts/                          # 最初のページ
GET /api/posts/?cursor=cD0xMjM=          # 次のページ

レスポンスには count がありません (計算するには結局全件スキャン)。

{
  "next": "http://.../?cursor=cD0xMjM=",
  "previous": null,
  "results": [...]
}

いつ何を #

状況推奨
小さなテーブル (~数万)、ページ UIPageNumberPagination
クライアントが自由に offset 調整LimitOffsetPagination
大きなテーブル (数十万 +)、無限スクロール、フィードCursorPagination
Twitter/Instagram スタイルのタイムラインCursorPagination

View 別の paginator オーバーライド #

グローバルと違う paginator を使いたければ pagination_class

ViewSet 別
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    pagination_class = PostCursorPagination


class CommentViewSet(viewsets.ModelViewSet):
    queryset = Comment.objects.all()
    serializer_class = CommentSerializer
    pagination_class = StandardPagination

ページネーションを オフ にしたければ pagination_class = None

get_queryset — ユーザー別 queryset #

#2 で見た IsOwnerlist には通りません — list はオブジェクト単位の検査がありません。自分の記事だけ見えるように するには get_queryset でフィルタリングします。

自分のものだけ
class PostViewSet(viewsets.ModelViewSet):
    serializer_class = PostSerializer

    def get_queryset(self):
        qs = Post.objects.all()
        if self.action == "list":
            # 一覧は公開記事 + 自分の記事
            if self.request.user.is_authenticated:
                return qs.filter(
                    Q(published=True) | Q(author=self.request.user)
                )
            return qs.filter(published=True)
        return qs   # retrieve/update はオブジェクト権限が別途検査

get_queryset() がフィルタリング / ページネーションの 出発点 という点が核心です。すべての backend がこの queryset の上で動作します。

キャッシングとの関係 #

同じクエリ / 同じページが頻繁に呼ばれるなら 上級 #4 キャッシング の view-level キャッシングが効果的です。

シンプルな view キャッシュ
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

    @method_decorator(cache_page(60))   # 60 秒
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)

ただしページネーション / フィルタ / ソートは クエリパラメータごとに違うレスポンス になります — cache_page は URL + クエリストリング単位でキャッシュするので自然に分離されます。ただし、認証ユーザーごとにレスポンスが異なる場合は vary_on_headers / ユーザー単位のキーが必要です。

select_related / prefetch_related — 一緒に行くべき道具 #

ページネーションが効果を出すには ORM クエリも N+1 を避ける必要があります。上級 #3 クエリ最適化 のパターンそのまま。

get_queryset で先にロード
def get_queryset(self):
    return (
        Post.objects.select_related("author")        # FK
        .prefetch_related("comments")                # reverse FK / M2M
        .all()
    )

ViewSet の queryset クラス属性は モジュール import 時点で 1 度 作られます — リクエストのたびに評価する必要のある動的ロジック (リクエストユーザーなど) は get_queryset() で解く必要があります。

レスポンスメタ — count / next / previous #

paginator はレスポンスを 1 つの dict で包みます。

PageNumber レスポンス
{
  "count": 1234,
  "next": "http://...?page=3",
  "previous": "http://...?page=1",
  "results": [
    { "id": 1, ... },
    { "id": 2, ... }
  ]
}

この形がほぼ標準なので、フロントも同じパターンで受けて処理すれば OK。クライアントは next が null でない間 follow します。

まとめ #

今回つかんだもの:

  • filter_backends パイプライン — DjangoFilter / Search / Ordering の 3 バックエンド
  • django-filterfilterset_fields のシンプル形、FilterSet クラスgte/in/method を表現
  • OrderingFilterordering_fields のホワイトリスト、デフォルト ordering
  • SearchFiltersearch_fields と prefix (^=@$)
  • 本物の検索は PG full-text または OpenSearch に分離
  • 3 つの paginator の位置:
    • PageNumberPagination — 一般的な UI
    • LimitOffsetPagination — クライアントが offset を直接
    • CursorPagination — 大きなテーブル / 無限スクロールの安全な答え
  • pagination_class で view 別オーバーライド、None でオフ
  • get_queryset() でユーザー別 / アクション別の分岐
  • select_related / prefetch_related と一緒に行ってこそ N+1 が破綻しない
  • view-level キャッシングとの結合

上級 #4 キャッシング の view キャッシュ、上級 #3 クエリ最適化 のインデックス / JOIN パターンがページネーションと自然に一緒に行きます — 大きなデータを扱う道具はいつも 1 つの束。

次回 (#4 Celery で非同期作業) では重い作業 — メール送信、外部 API 呼び出し、重い変換 — をレスポンスの流れから切り離す標準的な道具 Celery を扱います。

X