Pagination¶
Django Ninja Aio CRUD provides built-in async pagination support for efficiently handling large datasets in your API responses.
Overview¶
Pagination in Django Ninja Aio CRUD:
- Fully async - No blocking database queries
- Customizable - Override default behavior per ViewSet
- Type-safe - Proper type hints and validation
- Automatic - Works out of the box with APIViewSet
- Flexible - Support for multiple pagination styles
Default Pagination¶
PageNumberPagination¶
The default pagination class used by APIViewSet.
Features:
- Page-based navigation
- Configurable page size
- Total count included
- Next/previous page info
Default Configuration:
| Parameter | Default | Description |
|---|---|---|
page |
1 |
Current page number |
page_size |
10 |
Items per page |
max_page_size |
100 |
Maximum allowed page size |
Response Format¶
{
"count": 45,
"next": 3,
"previous": 1,
"results": [
{
"id": 11,
"title": "Article 11",
"created_at": "2024-01-15T10:30:00Z"
},
{
"id": 12,
"title": "Article 12",
"created_at": "2024-01-15T11:00:00Z"
}
]
}
Response Fields:
| Field | Type | Description |
|---|---|---|
count |
int |
Total number of items |
next |
int \| None |
Next page number (null if last page) |
previous |
int \| None |
Previous page number (null if first page) |
results |
list |
Array of items for current page |
Basic Usage¶
With APIViewSet¶
Pagination is automatically applied to list endpoints:
from ninja_aio import NinjaAIO
from ninja_aio.views import APIViewSet
from .models import Article
api = NinjaAIO()
class ArticleViewSet(APIViewSet):
model = Article
api = api
ArticleViewSet().add_views_to_route()
Generated endpoint:
Manual Usage¶
from ninja.pagination import PageNumberPagination
from django.http import HttpRequest
async def my_view(request: HttpRequest):
# Get queryset
queryset = Article.objects.all()
# Create paginator
paginator = PageNumberPagination()
# Paginate (accepts query params from request)
result = await paginator.apaginate_queryset(
queryset=queryset,
pagination=paginator,
request=request
)
return result
Query Parameters¶
page¶
Current page number (1-indexed).
Validation:
- Must be >= 1
- Returns 404 if page doesn't exist
page_size¶
Number of items per page.
Validation:
- Must be >= 1
- Cannot exceed
max_page_size - Defaults to pagination class default
Examples¶
First page with 10 items:
Second page with 25 items:
Maximum items per page:
Custom Pagination¶
Override Default Page Size¶
from ninja.pagination import PageNumberPagination
class LargePagePagination(PageNumberPagination):
page_size = 50 # Default 50 items per page
max_page_size = 200 # Allow up to 200 items
class ArticleViewSet(APIViewSet):
model = Article
api = api
pagination_class = LargePagePagination
Small Page Size for Mobile¶
class MobilePagination(PageNumberPagination):
page_size = 5
max_page_size = 20
class ArticleViewSet(APIViewSet):
model = Article
api = api
pagination_class = MobilePagination
AsyncPaginationBase¶
Base class for creating custom pagination.
Class Definition¶
from ninja.pagination import AsyncPaginationBase
class MyPagination(AsyncPaginationBase):
page_size: int = 10
max_page_size: int = 100
async def apaginate_queryset(
self,
queryset,
pagination,
request=None,
**params
):
# Custom pagination logic
pass
Required Methods¶
apaginate_queryset()¶
Main pagination method that processes the queryset.
Signature:
async def apaginate_queryset(
self,
queryset: QuerySet,
pagination: AsyncPaginationBase,
request: HttpRequest = None,
**params
) -> dict
Parameters:
| Parameter | Type | Description |
|---|---|---|
queryset |
QuerySet |
Django queryset to paginate |
pagination |
AsyncPaginationBase |
Pagination instance |
request |
HttpRequest |
HTTP request object |
**params |
dict |
Additional parameters |
Returns:
Dictionary with pagination metadata and results.
Custom Pagination Examples¶
Cursor-Based Pagination¶
from ninja.pagination import AsyncPaginationBase
from ninja import Schema
class CursorPaginationSchema(Schema):
cursor: str | None = None
page_size: int = 10
class CursorPagination(AsyncPaginationBase):
page_size = 10
max_page_size = 100
async def apaginate_queryset(self, queryset, pagination, request=None, **params):
cursor = params.get('cursor')
page_size = min(params.get('page_size', self.page_size), self.max_page_size)
# Apply cursor filtering
if cursor:
queryset = queryset.filter(id__gt=cursor)
# Fetch items + 1 to check if there's next page
items = []
async for item in queryset[:page_size + 1]:
items.append(item)
has_next = len(items) > page_size
results = items[:page_size]
next_cursor = None
if has_next and results:
next_cursor = str(results[-1].id)
return {
"next_cursor": next_cursor,
"results": results
}
class ArticleViewSet(APIViewSet):
model = Article
api = api
pagination_class = CursorPagination
Usage:
Response:
Offset-Based Pagination¶
class OffsetPagination(AsyncPaginationBase):
page_size = 10
max_page_size = 100
async def apaginate_queryset(self, queryset, pagination, request=None, **params):
offset = params.get('offset', 0)
limit = min(params.get('limit', self.page_size), self.max_page_size)
# Get total count
total_count = await queryset.acount()
# Slice queryset
items = []
async for item in queryset[offset:offset + limit]:
items.append(item)
return {
"count": total_count,
"offset": offset,
"limit": limit,
"results": items
}
class ArticleViewSet(APIViewSet):
model = Article
api = api
pagination_class = OffsetPagination
Usage:
# First 10 items
GET /article/?offset=0&limit=10
# Next 10 items
GET /article/?offset=10&limit=10
# Skip 20, get 15
GET /article/?offset=20&limit=15
Response:
Link Header Pagination¶
from django.http import HttpResponse
class LinkHeaderPagination(AsyncPaginationBase):
page_size = 10
max_page_size = 100
async def apaginate_queryset(self, queryset, pagination, request=None, **params):
page = params.get('page', 1)
page_size = min(params.get('page_size', self.page_size), self.max_page_size)
total_count = await queryset.acount()
total_pages = (total_count + page_size - 1) // page_size
start = (page - 1) * page_size
end = start + page_size
items = []
async for item in queryset[start:end]:
items.append(item)
# Build Link header
base_url = request.build_absolute_uri(request.path)
links = []
if page > 1:
links.append(f'<{base_url}?page={page-1}&page_size={page_size}>; rel="prev"')
if page < total_pages:
links.append(f'<{base_url}?page={page+1}&page_size={page_size}>; rel="next"')
links.append(f'<{base_url}?page=1&page_size={page_size}>; rel="first"')
links.append(f'<{base_url}?page={total_pages}&page_size={page_size}>; rel="last"')
return {
"results": items,
"_links": ", ".join(links)
}
Response Headers:
Link: <http://api.example.com/article/?page=1&page_size=10>; rel="first",
<http://api.example.com/article/?page=2&page_size=10>; rel="prev",
<http://api.example.com/article/?page=4&page_size=10>; rel="next",
<http://api.example.com/article/?page=10&page_size=10>; rel="last"
Disable Pagination¶
For Specific ViewSet¶
class ArticleViewSet(APIViewSet):
model = Article
api = api
pagination_class = None # Disable pagination
Now the list endpoint returns all items without pagination:
Conditional Pagination¶
class ConditionalPagination(PageNumberPagination):
async def apaginate_queryset(self, queryset, pagination, request=None, **params):
# Disable pagination if 'all' parameter is present
if params.get('all'):
items = []
async for item in queryset:
items.append(item)
return {"results": items}
# Otherwise use default pagination
return await super().apaginate_queryset(queryset, pagination, request, **params)
Usage:
Integration with Filtering¶
Pagination works seamlessly with query parameter filtering:
class ArticleViewSet(APIViewSet):
model = Article
api = api
query_params = {
"is_published": (bool, None),
"category": (int, None),
}
async def query_params_handler(self, queryset, filters):
if filters.get("is_published") is not None:
queryset = queryset.filter(is_published=filters["is_published"])
if filters.get("category"):
queryset = queryset.filter(category_id=filters["category"])
return queryset
Usage:
The filtering is applied first, then pagination is applied to the filtered queryset.
Performance Optimization¶
Count Optimization¶
For large datasets, counting can be expensive. Cache the count:
from django.core.cache import cache
class OptimizedPagination(PageNumberPagination):
async def apaginate_queryset(self, queryset, pagination, request=None, **params):
page = params.get('page', 1)
page_size = min(params.get('page_size', self.page_size), self.max_page_size)
# Try to get cached count
cache_key = f"count_{queryset.model.__name__}"
total_count = cache.get(cache_key)
if total_count is None:
total_count = await queryset.acount()
cache.set(cache_key, total_count, 300) # Cache for 5 minutes
# Rest of pagination logic
start = (page - 1) * page_size
end = start + page_size
items = []
async for item in queryset[start:end]:
items.append(item)
return {
"count": total_count,
"page": page,
"page_size": page_size,
"results": items
}
Select Related / Prefetch Related¶
Optimize queries when paginating related data:
class Article(ModelSerializer):
author = models.ForeignKey(User, on_delete=models.CASCADE)
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True)
tags = models.ManyToManyField(Tag, related_name="articles")
@classmethod
async def queryset_request(cls, request):
# Optimize queries before pagination
return cls.objects.select_related(
'author',
'category'
).prefetch_related(
'tags'
)
class ArticleViewSet(APIViewSet):
model = Article
api = api
Now pagination queries are optimized:
-- Single query with joins instead of N+1
SELECT article.*, user.*, category.*
FROM article
LEFT JOIN user ON article.author_id = user.id
LEFT JOIN category ON article.category_id = category.id
LIMIT 10 OFFSET 0;
Approximate Counts¶
For very large tables, use approximate counts:
class ApproximatePagination(PageNumberPagination):
async def apaginate_queryset(self, queryset, pagination, request=None, **params):
from django.db import connection
# Get approximate count from PostgreSQL statistics
with connection.cursor() as cursor:
cursor.execute(
"SELECT reltuples::bigint FROM pg_class WHERE relname = %s",
[queryset.model._meta.db_table]
)
approximate_count = cursor.fetchone()[0]
# Rest of pagination logic using approximate_count
# ...
Error Handling¶
Invalid Page Number¶
Invalid Page Size¶
# Request
GET /article/?page=1&page_size=1000
# Automatically clamped to max_page_size (100)
# Response
{
"count": 45,
"page": 1,
"page_size": 100,
"results": [...]
}
Custom Error Handling¶
class StrictPagination(PageNumberPagination):
async def apaginate_queryset(self, queryset, pagination, request=None, **params):
page_size = params.get('page_size', self.page_size)
if page_size > self.max_page_size:
raise ValueError(
f"page_size cannot exceed {self.max_page_size}"
)
# Continue with pagination
# ...
Testing Pagination¶
import pytest
from ninja.testing import TestAsyncClient
from myapp.views import api
@pytest.mark.asyncio
async def test_pagination():
client = TestAsyncClient(api)
# Create test data
for i in range(25):
await Article.objects.acreate(title=f"Article {i}")
# Test first page
response = await client.get("/article/?page=1&page_size=10")
assert response.status_code == 200
data = response.json()
assert data["count"] == 25
assert len(data["results"]) == 10
assert data["next"] == 2
assert data["previous"] is None
# Test middle page
response = await client.get("/article/?page=2&page_size=10")
data = response.json()
assert len(data["results"]) == 10
assert data["next"] == 3
assert data["previous"] == 1
# Test last page
response = await client.get("/article/?page=3&page_size=10")
data = response.json()
assert len(data["results"]) == 5
assert data["next"] is None
assert data["previous"] == 2
@pytest.mark.asyncio
async def test_invalid_page():
client = TestAsyncClient(api)
response = await client.get("/article/?page=999")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_page_size_limit():
client = TestAsyncClient(api)
# Request exceeds max_page_size
response = await client.get("/article/?page=1&page_size=1000")
data = response.json()
assert len(data["results"]) <= 100 # Clamped to max_page_size
Best Practices¶
- Choose appropriate page size:
# Mobile API
page_size = 10
# Desktop/Web API
page_size = 25
# Admin/Internal API
page_size = 100
- Set reasonable max_page_size:
- Cache expensive counts:
- Optimize queries:
- Use cursor pagination for infinite scroll:
- Consider approximate counts for huge tables:
See Also¶
- API ViewSet - Using pagination with ViewSets
- Model Util - Query optimization
- Authentication - Securing paginated endpoints
Next: Learn about Authentication to secure your API endpoints.