Django Rest Framework Cursos pagination with multiple ordering fields and filters
I have an issue with DRF, CursorPagination and Filters.
I have an endpoint. When I access the initial page of the enpoint I get a next URL
"next": "http://my-url/api/my-endpoint/?cursor=bz0yMDA%3D&date__gte=2025-04-25T10%3A00%3A00Z&date__lte=2025-04-26T10%3A00%3A00Z"
When I access this URL I get a previous URL
"next": "http://my-url/api/my-endpoint/?cursor=bz00MDA%3D&date__gte=2025-04-25T10%3A00%3A00Z&date__lte=2025-04-26T10%3A00%3A00Z",
"previous": "http://my-url/api/my-endpoint/?cursor=cj0xJnA9MjAyNS0wNC0yNSsxMCUzQTAwJTNBMDAlMkIwMCUzQTAw&date__gte=2025-04-25T10%3A00%3A00Z&date__lte=2025-04-26T10%3A00%3A00Z",
Now when I try to access the previous URL, I get an empty result list.
Here is the code for the endpoint
class RevenuePagination(CursorPagination):
page_size = 200
ordering = ['date', 'custom_channel_name', 'date', 'country_name', 'platform_type_code', 'id']
class RevenueFilter(django_filters.FilterSet):
class Meta:
model = Revenue
fields = {
'date': ['lte', 'gte'],
'custom_channel_name': ['exact'],
'country_name': ['exact'],
'platform_type_code': ['exact'],
}
class RevenueViewSet(viewsets.ModelViewSet):
permission_classes = [HasAPIKey]
queryset = Revenue.objects.all()
serializer_class = RevenueSerializer
filterset_class = RevenueFilter
pagination_class = RevenuePagination
ordering = ['date', 'custom_channel_name', 'date', 'country_name', 'platform_type_code', 'id']
ordering_fields = ['date', 'custom_channel_name', 'date', 'country_name', 'platform_type_code', 'id']
@revenue_list_schema()
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
From what I get, the problem seems to be that the previous URL cursor (cj0xJnA9MjAyNS0wNC0yNSsxMCUzQTAwJTNBMDAlMkIwMCUzQTAw
) gets decoded to r=1&p=2025-04-25+10:00:00+00:00
, which is not right, because only the date field is in the cursor, while I have specidied 6 fields in the ordering of the pagination.
I tried it narrowing down the order to ['date', 'id'], but it does not work.
All the fields in the ordering are actual DB fields (DateTime, Char or Integer), no properties.
I am really struggling, tried debugging with Google AI and ChatGPT, both came to a dead end.
ordering
can (and often should!) be multiple fields.
But you're right that only the first field is stored in the cursor token. However, the other fields are still applied:
# rest_framework/pagination.py
class CursorPagination(BasePagination):
[...]
def paginate_queryset(self, queryset, request, view=None):
[...]
'''
The below part is where the ordering fields come into play.
They are not stored in the token, instead they are reapplied
on every hit of the paginator.
'''
# Cursor pagination always enforces an ordering.
if reverse:
queryset = queryset.order_by(*_reverse_ordering(self.ordering))
else:
queryset = queryset.order_by(*self.ordering)
So this part of the investigation is a red herring.
What I'm willing to bet is the culprit is the fact that your ordering
list contains date
twice. I haven't tested it, but there's a chance that including the same field twice leads to some kind of nonsense SQL being generated at some point in this operation. It might also be data-dependent for all I know.
Try removing the duplicate date
.
Alternatively, inspect what .order_by()
produces for you and/or look at the raw SQL to verify that the query isn't malformed.
I manage to solve this by using another module `django-cursor-pagination`, and wrapping the paginator from that module in a Pagination class, as required by DRF:
https://pypi.org/project/django-cursor-pagination/
# CODE IS MOSTLY GENERATED BT CHAT GPT
from rest_framework.pagination import BasePagination
from rest_framework.response import Response
from rest_framework.utils.urls import replace_query_param, remove_query_param
from cursor_pagination import CursorPaginator
class CustomCursorPagination(BasePagination):
page_size = 10
ordering = None
after_query_param = 'after'
before_query_param = 'before'
page_size_query_param = 'page_size'
max_page_size = 100
def get_page_size(self, request):
try:
size = int(request.query_params.get(self.page_size_query_param, self.page_size))
if self.max_page_size:
return min(size, self.max_page_size)
return size
except (TypeError, ValueError):
return self.page_size
def paginate_queryset(self, queryset, request, view=None):
self.request = request
self.queryset = queryset
self.after = request.query_params.get(self.after_query_param)
self.before = request.query_params.get(self.before_query_param)
page_size = self.get_page_size(request)
self.paginator = CursorPaginator(queryset, ordering=self.ordering)
if self.after and self.before:
raise ValueError("Both 'after' and 'before' parameters cannot be used together.")
if self.after:
self.page = self.paginator.page(first=page_size, after=self.after)
elif self.before:
self.page = self.paginator.page(last=page_size, before=self.before)
else:
self.page = self.paginator.page(first=page_size)
self.has_next = self.page.has_next
self.has_previous = self.page.has_previous
self.next_cursor = self.paginator.cursor(self.page[-1]) if self.has_next else None
self.prev_cursor = self.paginator.cursor(self.page[0]) if self.has_previous else None
return list(self.page)
def _build_url_with_cursor(self, param, cursor_value):
url = self.request.build_absolute_uri()
url = remove_query_param(url, self.after_query_param)
url = remove_query_param(url, self.before_query_param)
return replace_query_param(url, param, cursor_value)
def get_next_link(self):
if not self.has_next or not self.next_cursor:
return None
return self._build_url_with_cursor(self.after_query_param, self.next_cursor)
def get_previous_link(self):
if not self.has_previous or not self.prev_cursor:
return None
return self._build_url_with_cursor(self.before_query_param, self.prev_cursor)
def get_paginated_response(self, data):
return Response({
'next': self.get_next_link(),
'previous': self.get_previous_link(),
'results': data
})
def get_schema_operation_parameters(self, view):
return [
{
'name': self.after_query_param,
'required': False,
'in': 'query',
'description': 'Cursor to fetch the next page of results.',
'schema': {'type': 'string'},
},
{
'name': self.before_query_param,
'required': False,
'in': 'query',
'description': 'Cursor to fetch the previous page of results.',
'schema': {'type': 'string'},
},
{
'name': self.page_size_query_param,
'required': False,
'in': 'query',
'description': f'Number of results per page (max {self.max_page_size})',
'schema': {'type': 'integer'},
}
]
And than use that class as the basis for my RevenuePagination class:
class RevenuePagination(CustomCursorPagination):
page_size = 500
max_page_size = 500
ordering = ['date', 'country_name', 'platform_type_code', 'custom_channel_name', 'id']