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 / ModelViewSet の list アクションは queryset を受け取って filter_backends を順に通した後にシリアライズします。バックエンドは 3 つが標準。
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-filterINSTALLED_APPS += ["django_filters"]最もシンプル — filterset_fields
#
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=trueFilterSet — より豊かな表現
#
同じフィールドでも gte、lt、in のような lookup が必要なら FilterSet クラス で。
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)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 — ソート
#
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 — テキスト検索
#
複数フィールドにまたがるシンプルな検索。
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=djangosearch_fields の prefix で検索モードを指定できます。
| prefix | 意味 |
|---|---|
| (なし) | icontains (部分一致、大文字小文字無視) |
^ | istartswith (前方一致) |
= | iexact (完全一致) |
@ | full-text search (Postgres のみ) |
$ | regex |
search_fields = ["^title", "=email", "body"]本物の検索は別の道具で #
SearchFilter は小さなデータで素早く始めるためのものです。本物の検索が必要なら:
- PostgreSQL full-text search (
SearchVector、SearchQuery) - OpenSearch / Elasticsearch / Meilisearch のような検索エンジン
上級 #3 クエリ最適化 で見たインデックス戦略と一緒に行ってこそ大きなデータで生き残れます。
Pagination — 3 つのページネーション #
DRF が標準で提供する 3 つの paginator。それぞれ位置が違います。
| PageNumberPagination | LimitOffsetPagination | CursorPagination | |
|---|---|---|---|
| クエリ | ?page=2 | ?limit=20&offset=40 | ?cursor=cD0xMjM= |
| メタ | count、next、previous | count、next、previous | next、previous (no count) |
| 大きなテーブル | 遅くなる (count + offset) | 非常に遅くなる (offset が深いほど) | 速い (インデックス使用) |
| ソート変更 | 可能 | 可能 | 固定 ordering が必要 |
| データ追加 / 削除 | ページぶれ | ページぶれ | 安定 |
| 向く場面 | 一般的な UI ページネーション | クライアントが offset を直接調整 | 無限スクロール、大きなテーブル |
PageNumberPagination #
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 #
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 #
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 が結果をそこまで全部数える必要があるからです。
SELECT * FROM blog_post ORDER BY created_at DESC LIMIT 20 OFFSET 100000;このクエリは 100,020 行を読んで最後の 20 だけを返します。ページが深いほど比例して遅くなり、その間に行が追加 / 削除されると同じ記事が 2 度見えたり抜けたりします (ページぶれ)。
CursorPagination は インデックスでジャンプ します。
SELECT * FROM blog_post WHERE created_at < '2026-05-01 12:00:00' ORDER BY created_at DESC LIMIT 20;最後に見た行の created_at を覚えて、「それより以前」で次のページを取得します。データサイズと無関係に一定時間。
from rest_framework.pagination import CursorPagination
class PostCursorPagination(CursorPagination):
page_size = 20
ordering = "-created_at" # インデックスがあるフィールドであるべき
cursor_query_param = "cursor"class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.all()
serializer_class = PostSerializer
pagination_class = PostCursorPaginationGET /api/posts/ # 最初のページ
GET /api/posts/?cursor=cD0xMjM= # 次のページレスポンスには count がありません (計算するには結局全件スキャン)。
{
"next": "http://.../?cursor=cD0xMjM=",
"previous": null,
"results": [...]
}いつ何を #
| 状況 | 推奨 |
|---|---|
| 小さなテーブル (~数万)、ページ UI | PageNumberPagination |
| クライアントが自由に offset 調整 | LimitOffsetPagination |
| 大きなテーブル (数十万 +)、無限スクロール、フィード | CursorPagination |
| Twitter/Instagram スタイルのタイムライン | CursorPagination |
View 別の paginator オーバーライド #
グローバルと違う paginator を使いたければ pagination_class。
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 で見た IsOwner は list には通りません — 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 キャッシングが効果的です。
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 クエリ最適化 のパターンそのまま。
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 で包みます。
{
"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-filter—filterset_fieldsのシンプル形、FilterSetクラス でgte/in/methodを表現OrderingFilter—ordering_fieldsのホワイトリスト、デフォルトorderingSearchFilter—search_fieldsと prefix (^、=、@、$)- 本物の検索は PG full-text または OpenSearch に分離
- 3 つの paginator の位置:
PageNumberPagination— 一般的な UILimitOffsetPagination— クライアントが 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 を扱います。