Django QuerySets can be paginated using two primary methods: offset-based (often called "limit/offset") and cursor-based.

Let’s see Django’s built-in Paginator class in action. This is the most common way to do offset pagination, and it’s straightforward:

from django.core.paginator import Paginator

# Assume 'my_objects' is a QuerySet of some model
my_objects = MyModel.objects.all().order_by('id')

paginator = Paginator(my_objects, 10) # Show 10 objects per page

page_number = 1
page_obj = paginator.get_page(page_number)

print(page_obj.object_list) # This will be a list of up to 10 objects
print(page_obj.has_next())  # True if there's a next page
print(page_obj.next_page_number()) # The number of the next page

This Paginator class uses offset pagination internally. When you request page 2, it effectively translates to a SQL query like SELECT ... FROM my_model ORDER BY id LIMIT 10 OFFSET 10.

The problem with this approach arises when you have a rapidly changing dataset. Imagine you’re on page 1, and an item is deleted from page 2 before you request it. When you then request page 2, the deleted item is gone, and the item that was originally on page 3 now appears on page 2. Your users see a jump, or worse, they might miss items entirely. This is because OFFSET is a positional instruction – "give me items starting at this position" – and the positions can shift if items are added or removed.

Cursor-based pagination, on the other hand, uses a "pointer" to the last item seen. Instead of saying "give me items from position X onwards," you say "give me items that come after this specific item." This is typically implemented by ordering your QuerySet by a unique, stable field (like id or a timestamp) and then filtering based on that field’s value.

Here’s how you’d approximate cursor pagination in Django, often using a library like django-graphql-cursor or by hand-rolling it in your views:

# Assume MyModel has an 'id' field that is unique and increasing
# And assume we have a 'last_id' from a previous request
last_id = request.GET.get('after_id') # e.g., "123"

base_queryset = MyModel.objects.order_by('id')

if last_id:
    # Filter for objects with an ID strictly greater than the last seen ID
    queryset = base_queryset.filter(id__gt=last_id)
else:
    # First request, get the first page
    queryset = base_queryset

# Apply a limit
paginated_queryset = queryset[:10] # Get up to 10 items

# To get the 'next cursor' for the *next* request, you'd look at the last item
if paginated_queryset:
    next_cursor = paginated_queryset[-1].id
else:
    next_cursor = None

This id__gt=last_id filter is the core of cursor pagination. It’s a stable instruction: "give me items whose id is greater than 123." If items are added or deleted, this instruction remains valid and won’t cause items to be skipped or duplicated. The "cursor" is simply the value of the id of the last item returned.

The most surprising true thing about cursor pagination is that it’s fundamentally a seek operation, not an enumerate operation. Offset pagination is like asking for page 5 of a book, which implies you know how many pages came before it. Cursor pagination is like saying, "start reading where I left off, right after this specific sentence." This distinction matters immensely for performance and correctness on large, dynamic datasets.

Internally, offset pagination translates to LIMIT and OFFSET in SQL. For example, LIMIT 10 OFFSET 1000000 can be slow because the database has to scan through the first million rows to discard them before it can start collecting the next ten. Cursor pagination, using WHERE id > 1000000 LIMIT 10, is much faster because the database can use an index on the id column to jump directly to the relevant rows.

The exact levers you control are the ordering field and the filtering logic. You must have a field that is unique and, ideally, monotonically increasing or decreasing for cursor pagination to work reliably. Common choices are primary keys (id), creation timestamps, or UUIDs if you’re careful about their generation.

The one thing most people don’t know is that you can implement "previous page" functionality with cursor pagination by using id__lt and reversing the order of your QuerySet. This requires careful handling of the cursor value and the direction of iteration.

The next problem you’ll encounter is handling "previous page" requests efficiently with cursor pagination.

Want structured learning?

Take the full Django course →