Skip to content
_lvleo21
← All articles

How do you paginate lists and APIs in Django?

Pagination with ListView and paginate_by, the page_obj attributes in the template, numbered links, filters kept in the URL and API pagination with Django REST Framework.

  • django
  • python

I first published this article on Medium (in Portuguese) in June 2020. In this version I reviewed the code for current Django, fixed a few mistakes in the original text and added features that came out later, such as get_elided_page_range and the {% querystring %} tag.

In this article we will add pagination to a Django project using class-based views and show how to use the page data in the template. I assume you already know the basics of Django, class-based views and HTML.

The examples use a model called People, with a name, age and gender:

# models.py
from django.db import models


class People(models.Model):
    name = models.CharField(max_length=100)
    age = models.PositiveIntegerField()
    gender = models.CharField(max_length=20)

    def __str__(self):
        return self.name

The view

ListView, one of Django’s generic views, represents a list of objects and already knows how to paginate. You only need three attributes: model, with the model to list; template_name, with the HTML that receives the data; and paginate_by, with the number of objects per page.

# views.py
from django.views.generic import ListView

from .models import People


class PeopleListView(ListView):
    model = People
    template_name = "people/people_list.html"
    paginate_by = 2
    ordering = ["name"]

ordering was missing from the first version of this article, and it matters. Without a defined order, the database may return the rows in any sequence, and the same person can show up on two pages while another disappears. Django warns about this with an UnorderedObjectListWarning when you paginate an unordered queryset. Setting ordering in the model’s Meta also solves it.

Then register the view in urls.py:

# urls.py
from django.urls import path

from .views import PeopleListView

urlpatterns = [
    path("people/", PeopleListView.as_view(), name="people-list"),
]

The current page comes from the page parameter in the URL: /people/?page=3 shows the third page. ListView also accepts ?page=last and responds with a 404 when the number does not exist, so you don’t have to handle those cases by hand.

What reaches the template

With paginate_by set, the view puts page_obj (the current page), paginator (the object that split the list) and is_paginated, which is only true when there is more than one page, in the template context.

In the original version I wrote that page_obj holds every object in the database. It doesn’t: it only holds the objects of the current page. With ten people registered and paginate_by = 2, each page has two people and the listing has five pages. These are the most useful attributes:

Attribute What it returns
page_obj.number Current page number
page_obj.has_next True if there is a next page
page_obj.has_previous True if there is a previous page
page_obj.next_page_number Next page number (on page 3, returns 4)
page_obj.previous_page_number Previous page number (on page 3, returns 2)
page_obj.start_index / end_index Position of the page’s first and last items in the full list
page_obj.paginator.num_pages Total number of pages
page_obj.paginator.count Total number of objects across all pages
page_obj.paginator.page_range Range with every page number; with five pages, it is range(1, 6)

next_page_number and previous_page_number raise EmptyPage when the page does not exist. That is why they always sit inside an if with has_next or has_previous in the examples below.

Building the template

The listing is a for over page_obj. {% empty %} covers the case where nobody is registered:

{% for person in page_obj %}
  <p>Name: <b>{{ person.name }}</b></p>
  <p>Age: <b>{{ person.age }}</b></p>
  <p>Gender: <b>{{ person.gender }}</b></p>
{% empty %}
  <p>No people registered.</p>
{% endfor %}

The navigation has a link to the previous page, shown only if it exists, a counter in the middle and a link to the next page. href="?page=..." keeps the current path and changes only the page number:

{% if is_paginated %}
  <nav aria-label="Pagination">
    {% if page_obj.has_previous %}
      <a href="?page={{ page_obj.previous_page_number }}">Previous page</a>
    {% endif %}

    <span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>

    {% if page_obj.has_next %}
      <a href="?page={{ page_obj.next_page_number }}">Next page</a>
    {% endif %}
  </nav>
{% endif %}

With ten people and two per page, the first screen shows two people, the text “Page 1 of 5” and only the next page link.

To show the page numbers, the obvious path is to loop over page_range. That works with five pages, but with three hundred it becomes a huge row of links. Since Django 3.2 the paginator has get_elided_page_range, which returns only the pages near the current one and near the ends, with ellipses in between (for example, 1 … 7 8 9 10 11 … 300).

Since the method takes the current page number, it has to be called in the view:

class PeopleListView(ListView):
    model = People
    template_name = "people/people_list.html"
    paginate_by = 2
    ordering = ["name"]

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        page = context["page_obj"]
        context["page_range"] = page.paginator.get_elided_page_range(
            page.number, on_each_side=2, on_ends=1
        )
        return context

In the template, the ellipses come as paginator.ELLIPSIS, so you can tell them apart from the numbers:

{% for number in page_range %}
  {% if number == page_obj.paginator.ELLIPSIS %}
    <span>{{ number }}</span>
  {% elif number == page_obj.number %}
    <span aria-current="page">{{ number }}</span>
  {% else %}
    <a href="?page={{ number }}">{{ number }}</a>
  {% endif %}
{% endfor %}

Keeping filters in the URL

href="?page=2" has a problem that only shows up once the listing gets a search or filters. If the URL is /people/?q=ana&page=1, the next page link goes to /people/?page=2 and the search is lost.

Django 5.1 added the {% querystring %} tag, which copies the parameters of the current URL and changes only the ones you pass. It already returns the string starting with ?:

{% if page_obj.has_next %}
  <a href="{% querystring page=page_obj.next_page_number %}">Next page</a>
{% endif %}

On /people/?q=ana&page=1, this link points to ?q=ana&page=2. Before 5.1, the way out was writing your own template tag that did the same with request.GET.copy().

API pagination with Django REST Framework

When the listing is an API endpoint, Django REST Framework does the paginating, with its own classes for it. To turn it on for every view at once, set it in settings.py:

REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}

The response then comes with the total, the links to the neighboring pages and the results of the current page:

{
  "count": 10,
  "next": "https://api.example.com/people/?page=2",
  "previous": null,
  "results": []
}

To let the client choose the page size without leaving room for a page_size=100000, create a subclass with a limit:

from rest_framework.pagination import PageNumberPagination


class PeoplePagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = "page_size"
    max_page_size = 100

And use it in the view with pagination_class = PeoplePagination.

Page number pagination runs a COUNT(*) on every request to compute the total. On large tables that count is expensive, and rows inserted while the user browses shift items between pages. In those cases the alternative is CursorPagination, which moves through an opaque cursor instead of numbers. It does not report the total or let you jump to a specific page, and it requires ordering by a unique field that does not change, such as the creation date:

from rest_framework.pagination import CursorPagination


class PeopleCursorPagination(CursorPagination):
    page_size = 20
    ordering = "-created_at"

The created_at in this example assumes a creation date field on the model, which the People model from the start of the article does not have.

References