How to Implement List View Pagination with Django

Pagination is an important method to restrict the number of database objects in any given view. Pagination implemented well enables users to navigate your content as they need it in addition to keeping your site efficient by only rendering an object's assets required for a given page.

For my use case and the example documented here, I would like to present 6 blog posts at a time unless there are 3 or less remaining posts. In that case, I will present between 7 and 9 posts on the final paginated page.

Pagination will often want to be re-used for the different views that you will present to your visitors. In the case of blog posts, you may wish to present:

  • Posts - by author
  • Posts - by category
  • Posts - by date period (perhaps by month or year)

The approach and technologies used are Django's class-based views (CBVs), Django's ORM queries and Bootstrap styling to make it a fancy paginator. We'll also use DataGrip to inspect the database schema to give a clear understanding of how the main tables for the project are connected.

As the number of posts grows, it will be increasingly important to reduce the number of queries made to the database, especially when your database models have relationships with other models.

We will need to use Django's prefetch_related and select_related methods to reduce the number of queries made to the database.

Let's start by taking a look at the important parts of the models. There are 4 important models within the database schema to consider.

Django app.Model Postgres Table
Comments
auth.User auth_user This is Django's standard user authentication model
users.Profile users_profile This has a 1-to-1 relationship with the User model
blog.Category blog_category This has a many-to-many relationship with the Post model
blog.Post blog_post Similarly, this has a many-to-many relationship with the Category model. It also has a through relationship to the Profile model using the User model as an intermediary.
  blog_post_categories This isn't explicitly declared in the project. It's constructed by Django's migration by declaring a many-to-many relationship. It's a joining table that holds the category_id and the post_id for any instance of a many-to-many relationship.

 

Some additional design considerations are:

  • Every time a user is created, a corresponding profile is set up using Django's signals.
  • It's not possible for a user to delete their profile. This is only possible from the admin back-end with superuser permissions.
  • A post can only have one author (i.e. user/profile).
  • A post can belong to many categories and a category can contain many posts, therefore their relationship is a many-to-many one (i.e. uses a ManyToManyField).

To illustrate, this is the relevant part of the schema.

Blog Database Schema

In terms of the relevant models, they look like this:

# ./apps/blog/models.py

# The fields in this model do not have any attachment to another model
class Category(models.Model):
    name = models.CharField(max_length=16)
    slug = models.SlugField(max_length=16, unique=True)
    created_date = models.DateTimeField(auto_now_add=True)


class Post(models.Model):
    STATUS = (
        (0, 'Draft'),
        (1, 'Publish')
    )

    ...  # Other irrelevant fields

    status = models.IntegerField(choices=STATUS, default=0)
    author = models.ForeignKey(
        get_user_model(), related_name='author', on_delete=models.CASCADE)
    categories = models.ManyToManyField(
        Category,
        related_name='posts',
        help_text='Select more than one category by holding down Ctrl or Cmd key'
    )

    # Model Managers
    objects = models.Manager()
    published = PublishedManager()
# ./apps/users/models.py

class Profile(models.Model):

    class AuthorView(models.IntegerChoices):
        USERNAME = 0
        FULL_NAME = 1

    user = models.OneToOneField(
        get_user_model(), related_name='user', on_delete=models.CASCADE)
    ...  # Other irrelevant fields/methods

I have also implemented a manager which stores the same queryset which will be used many times throughout the project. Notice that within the models.py file, the PublishedManager class was instantiated with the published model manager variable.

# ./apps/blog/managers.py

from django.db import models


class PublishedManager(models.Manager):
    def get_queryset(self):
        qs = super(PublishedManager, self).get_queryset().filter(status=1)
        return qs.prefetch_related('categories').select_related('author__user')

The main queryset variable is used to drive the behaviour of retrieiving the required posts for any other list views within the project. This retrieves all categories associated with a post (since a post can belong to many categories). It also selects any objects from the User model associated with the post's author and the corresponding related_name reverse relationship defined in the Profile model. The queryset is then filtered to only include posts with a status of published which is a standard desired view throughout the project since the publication will never want to display draft posts.

The most important part within the QuerySet API Reference page of the Django docs and one to really try to internalise is quoted here:

select_related works by creating an SQL join and including the fields of the related object in the SELECT statement. For this reason, select_related gets the related objects in the same database query. However, to avoid the much larger result set that would result from joining across a ‘many’ relationship, select_related is limited to single-valued relationships - foreign key and one-to-one.

prefetch_related, on the other hand, does a separate lookup for each relationship, and does the ‘joining’ in Python. This allows it to prefetch many-to-many and many-to-one objects, which cannot be done using select_related, in addition to the foreign key and one-to-one relationships that are supported by select_related.

Implementing the Views

I start with a PostView which I'm setting up as a parent class so that all of the properties and behaviours can be inherited in select subsequent views since these will be shared for the majority of the other list views within the project.

# ./apps/blog/views.py

class PostView(ListView):
    """
    Custom view sets default behaviour for all list views to subclass
    and inherit for their own implementation
    """
    model = Post
    context_object_name = 'posts'
    category_list = Category.objects.all().prefetch_related('posts')
    extra_context = {'categories_list': category_list}
    queryset = Post.published.all()

    def get_context_data(self, **kwargs):
        """ Facilitates pagination and post count summary """
        context = super(PostView, self).get_context_data(**kwargs)
        context['current_page'] = context.pop('page_obj', None)
        return context

The category_list variable included as extra_context exists so that they can be iterated upon within the site's sidebar. Since the relationship between categories and posts is a many-to-many one, it requires the use of the prefetch_related method to reduce the number of queries used.

The queryset declaration is simply a reference to the queryset declared within the published model manager. This means that I do not need to repeat the prefetch_related and select_related logic within any of the other views. The manager has also handled the filtering to published posts, therefore this filtering logic does not need to be repeated here.

The get_context_data method inherits all of the properties and behaviours from Django's generic ListView and any of its other ancestors. See Classy Class-Based Views for more detail. The remaining functionality replaces the generic page_obj context dictionary key with a more user-friendly current_page one. It can be used for any paginated list view implemented throughout the project. This helps with giving the template logic user-friendly and meaningful names which helps with readability and maintainability of the code in the future.


Next up, I define a HomeView which as the view's name suggests is for the blog's home page. This is how it looks.

# ./apps/blogs/views.py

class HomeView(PostView):
    """ Drives the list of posts returned on the blog's home page """
    template_name = 'blog/home.html'
    paginate_by = 6
    paginate_orphans = 3

Notice how it starts by subclassing from PostView, therefore inheriting all of the attributes and methods associated with it. This means we will not need to define these again here.

The paginate_by attribute specifies that there will be 6 objects on any individual paginated page.

The paginate_orphans attribute specifies that if there are 3 or less remaining objects when the total number of objects is divided by 6, these orphans will also be displayed on the previous page. As an example, if there are 14 posts within the home page's view, there will be 1 page of 6 posts, and 1 page of 8 posts (i.e. 6 ordinarily paginated and 2 orphans). This prevents the 2 isolated posts looking lonely on their own page. 😀

There are other implemented views too which displays the posts by category or author, however they use the same pagination implementation within their respective templates because they also include the below snippet.

The HTML for the pagination looks like:

<!-- ./apps/blog/templates/blog/components/pagination_nav.html -->

<ul class="pagination justify-content-center my-5">
  {% if current_page.has_previous %}
  <!-- 'First' and 'Previous' Buttons -->
    <li class="page-item">
      <a class="page-link" href="?page=1">|&lt; First</a>
    </li>
    <li class="page-item">
      <a class="page-link" href="?page={{ current_page.previous_page_number }}">&lt; Previous</a>
    </li>
  {% endif %}

  <!-- Numbered Buttons -->
  {% for page in paginator.page_range %}
    {% if current_page.number == page %}
      <li class="page-item">
        <a class="page-link" href="?page={{ page }}"><strong><u>{{ page }}</u></strong></a>
      </li>
    {% elif page > current_page.number|add:'-3' and page < current_page.number|add:'3' %}
      <li class="page-item">
        <a class="page-link" href="?page={{ page }}">{{ page }}</a>
      </li>
    {% endif %}
  {% endfor %}

  <!-- 'Next' and 'Last' Buttons -->
  {% if current_page.has_next %}
    <li class="page-item">
      <a class="page-link" 
        href="?page={{ current_page.next_page_number }}">Next &gt;</a>
    </li>
    <li class="page-item">
      <a class="page-link" href="?page={{ current_page.paginator.num_pages }}">Last &gt;|</a>
    </li>
  {% endif %}
</ul>

It's included within the relevant template(s) with a simple conditional statement and template inclusion like this:

<!-- ./apps/blog/templates/blog/components/pagination_nav.html -->

{% extends 'base.html' %}

{% block title %}{{ block.super }} | Blog Home{% endblock title %}

{% block content %}

  ...

  {% if is_paginated %}{% include 'components/pagination_nav.html' %}{% endif %}
{% endblock content %}

You should now be able to view any list view of objects using pagination. Of course, you can now customise the Sass and HTML for your paginator to your liking.

For reference, this post was written with the GitHub source code referenced at the following commit: 6afe13d

The post was updated when the project was using Python 3.8.5 and Django 3.1.1. For a complete list of the project's packages, please refer to the Pipfile at the commit above.