None

How Django ListView Handles Page Numbers

A look at how the Django ListView class-based view utilizes page numbers to navigate within a QuerySet containing many records..

Background

Imagine a blog or news site with a page for browsing articles. As the site grows, so does the number of articles, potentially overwhelming users with long, unwieldy lists or slowing down page loads. Pagination solves this by breaking the content into manageable chunks, displaying only a subset of articles at a time.

In Django, the ListView class-based view, powered by the MultipleObjectMixin, makes this process seamless. The QuerySet is divided into pages based on the paginate_by attribute, which establishes the maximum number of items to appear on a given page. The current page is tracked in the URL as a query parameter (e.g., "/articles/?page=2"), or as a URL kwarg (e.g., "/articles/2/"), allowing users to navigate large datasets while maintaining a smooth and responsive experience.

Let's take a look at how Django accomplishes this.

 

The discussion here assumes Django 5.2.

The Code

The core page management logic resides within the paginate_queryset method of the MultipleObjectMixin. Within this method, the page number is retrieved and validated.

# MultipleObjectMixin.paginate_queryset


        page_kwarg = self.page_kwarg
        page = self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 1
        try:
            page_number = int(page)
        except ValueError:
            if page == "last":
                page_number = paginator.num_pages
            else:
                raise Http404(
                    _("Page is not “last”, nor can it be converted to an int.")
                )

GitHub

First, the page_kwarg attribute is retrieved. The page_kwarg is:

[a] string specifying the name to use for the page parameter. The view will expect this parameter to be available either as a query string parameter (via request.GET) or as a kwarg variable specified in the URLconf. Defaults to page.

docs

As mentioned in the quote above, the page_kwarg defaults to "page." In most situations, you can and should utilize the default value. The only circumstance I see myself deviating from the default value would be in some rare situation where parameter name collision is occurring. It's hard to get more semantic than "page" so best to leave it alone where possible.

Next, we try to retrieve the actually page number from the URL. Django first looks for the page number in the URL kwargs. If not found, the query parameters are checked next (request.GET). If neither is found, the integer 1 is provided as a default.

Personally, I like to just use the query parameter "?page=" instead of building URL patterns around page numbers. Because the page might not be provided, you need to use regex (re_path) or construct separate URL patterns for both when the kwarg is, and is not, present.

Consider the following:

# urls.py

from django.urls import path, re_path

from .views import ArticleListView  # a hypothetical ListView for displaying lists of articles


# using regex
urlpatterns = [
    re_path(r'^articles/(?P<page>\d+)/$', ArticleListView.as_view(), name='article-list'),
]

# OR separate patterns

urlpatterns = [
    path('articles/', ArticleListView.as_view(), name='article-list'),
    path('articles/page/<int:page>/', ArticleListView.as_view(), name='article-list-paginated'),
]

If I were to utilize a similar approach, I'd probably go with the regex. However, there's still a problem with the above. The page might not be a number.

 

Pages can be a string in addition to numbers! For example "?page=last"

This leads into the next step of the page number logic flow.

        try:
            page_number = int(page)
        except ValueError:
            if page == "last":
                page_number = paginator.num_pages

Depending on how the URL is structured (page number as a kwarg or a query parameter), the page number might be an integer or it might still be a string. Using the query parameter approach, it's a string. Because of this, Django first attempts to convert the value to an integer if it's not one already.

If the value is a string, direct type inference may still not be possible. That's because the value may not be a string representation of an integer. Instead, it could be the word "last." This scenario is handled in this try/catch block. If the value is indeed "last," then the page count (indexed at 1) is returned from the paginator object providing effectively the page number of the last page.

Of course, what's contained in URL may not be an integer, a string representation of integer, or the string "last." URLs can be manipulated so unexpected values may be received. If that's the case, Django will raise a Http404 exception.

If we've made it this far and we have in hand a value without an exception being raised, then we have a good page number value. But, it doesn't mean the page actually exists in the QuerySet. The URL could be easily manipulated to include a technically valid page number (e.g., "?page=999"), except there may not be a matching page 999 in the QuerySet. Enter the last step in the paginate_queryset method where page is confirmed to exist or an Http404 exception will be raised.

        try:
            page = paginator.page(page_number)
            return (paginator, page, page.object_list, page.has_other_pages())
        except InvalidPage as e:
            raise Http404(
                _("Invalid page (%(page_number)s): %(message)s")
                % {"page_number": page_number, "message": str(e)}
            )

Last, notice we're not just returning the page, we're additionally sending along the paginator object among other things we'll need down the line. This avoids duplicate queries in downstream methods.

Final Thoughts

Simple yet powerful. While Django's implementation of page numbers isn't technically complex, it's a great solution for working with a QuerySet containing many records. The ListView class powered by the MultipleObjectMixin allows the user to gracefully navigate within a large dataset while handling validation and edge cases.

While this discussion didn't introduce any new code or build custom adaptations, I find it useful to reference the source code of the frameworks we rely on to build our projects and often. It's good practice to look under the hood and see how things work so we can ultimately build better, more maintainable software.

Further Reading

Check out these articles that explore how to return to a certain page after first navigating away:

Details
Published
July 27, 2025
Next
July 2, 2025

Django Mixin to Find Any Object's Page Number in Paginated QuerySets

Build a reusable Django mixin that finds an object's page number in paginated QuerySets, ensuring users return to the correct page after performing operations on individual items.