How to Show a Range of Page Numbers using Django's Pagination

Posting this here in case anyone else has this problem and is searching for it.

Django provides a very helpful way to break up long lists into separate pages. It's built into the generic ListView. Simply by including a paginate_by parameter in your view class, you'll automatically get a paginated list of results. I won't go into all the details of how this works because the Django documentation covers it very well.

One thing the built-in paginator doesn't do is provide you with a window of pages around the current page. This is useful when you have an especially long list that is broken into many pages. In your navigation, you probably don't want to show links to tens or hundreds of pages or however many there might be. At the same time, showing only the current page with links the the first and last pages as the Django documentation suggests can leave it feeling a little sparse. For example, you might want to show links to the previous three pages and the next three pages around the current page, allowing the user to jump forward or backward by more than one page.

One way to do this would be to subclass the Paginator class and add a method that returns the range of pages, but then we'd also have to modify the ListView class to use the new Paginator and it gets a little messy. A simpler method I came up with is to make a custom template filter than can generate the desired range from the current page number, and to use this to create the range for a for loop when displaying our navigation in the template.

The basic function is not very complicated. We get a number of the current page and we want to generate a range from, say, page - 3, to page + 3, with a few caveats: the starting number can't be less than 1 and the ending number can't be more than the total number of pages. Here's how we'd do that:

from django import template

register = template.Library()

@register.filter
def page_window(page, last, size=7):
    if page < size // 2 + 1:
        return range(1, min(size+1, last + 1)) # remember the range function won't
                                                # include the upper bound in the output
    else:
        return range(page - size // 2, min(last + 1, page + 1 + size // 2))

The @register.filter decorator tells Django that this is a template filter. In order for Django to find it we should store the code in a file under app_name /template_tags/. In my case, I saved it as main/template_tags/main_extra.py. Refer to Django's documentation on writing custom template tags and filters for more information.

Now we can load the filter and use it in a template.

{% load main_extra %}
<nav aria-label="page navigation">
    <ul class="pagination pagination-sm justify-content-center">
        {% if page_obj.has_previous %}
        <li class="page-item">
            <a class="page-link" href="?page=1">&laquo; first</a>
        </li>
        <li class="page-item">
            <a class="page-link" href="?page={{ page_obj.previous_page_number }}">previous</a>
        </li>
        {% endif %}
        {% for page_number in page_obj.number|page_window:page_obj.paginator.num_pages  %}
        <li class="page-item {% if page_number == page_obj.number %}active{% endif %}">
            <a class="page-link" href="?page={{ page_number }}">
                {{ page_number }}
                {% if page_number == page_obj.number %}
                of {{ page_obj.paginator.num_pages }}
                {% endif %}
            </a>
        </li>
        {% endfor %}
        {% if page_obj.has_next %}
        <li class="page-item">
            <a class="page-link" href="?page={{ page_obj.next_page_number }}">next</a>
        </li>
        <li class="page-item">
            <a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">last &raquo;</a>
        </li>
        {% endif %}
    </ul>
</nav>

This example happens to use Bootstrap's classes to style the pagination, but however it's displayed isn't important. The key part is the for loop in the middle.

{% for page_number in page_obj.number|page_window:page_obj.paginator.num_pages  %}
            <li class="page-item {% if page_number == page_obj.number %}active{% endif %}">
                <a class="page-link" href="?page={{ page_number }}">
                    {{ page_number }}
                    {% if page_number == page_obj.number %}
                    of {{ page_obj.paginator.num_pages }}
                    {% endif %}
                </a>
            </li>
{% endfor %}

Here the filter page_window is used to create a range of the page_obj.number +/- 3. This will create a moving window centered on the current page as we navigate through the pages. The +/- 3 is based on the default parameter size in the page_window function. Unfortunately, custom filters in Django are limited to only two arguments, the value before the | that is passed as the first argument and an optional second argument we pass after the :. We're using both of these for the current page number and the total number of pages, so we can't customize the size of the window without rewriting the function. One option might be to have it grow larger up to a point based on the total number of pages.

Anyway, I hope this is helpful for anyone else facing the same problem.