This website is made possible by displaying online advertisements to our visitors.
Please consider supporting us by disabling your ad blocker. Thank you for your support.
This website is made possible by displaying online advertisements to our visitors.
Please consider supporting us by disabling your ad blocker.

Django Pagination with Function Based View

May 31 2021 Yacine Rouizi
Django Pagination Function Based View
Django Pagination with Function Based View

These days the majority of websites contain the pagination system. Pagination is the fact of splitting data across several pages in order to speed up the loading of the page and improve the user experience. In this tutorial, we will see in detail how pagination works in the Django framework.

Project Configuration

To follow this guide, you need to create a new project and do some configurations. So, first, open the terminal and run these commands:

mkdir dj-pagination 
cd dj-pagination
python3 -m venv venv
source venv/bin/activate
pip install django
django-admin startproject app .
django-admin startapp core
python manage.py migrate
python manage.py createsuperuser
Username: rouizi
Email address: 
Password:         # 1234
Password (again): # 1234

Now, open settings.py and add the following:

# app/settings.py
# ...
INSTALLED_APPS = [
    # ...
    'core', # Add this line
]
# ...
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / 'templates'], # Add this line
        # ...
    },
]
# ...

and, finally, create the templates directory:

mkdir templates

The Paginator Object

Django uses the Paginator class to split a Queryset object (or other objects with a count() or __len__() method) into Page objects.

The paginator class takes two parameters (actually, it accepts 4 but 2 are optional): the first one is the list of objects that we want to split, and the second one is the number of objects to display per page.

Let's open the shell and see how this class works:

$ python manage.py shell
# import the Paginator class and the User class
>>> from django.core.paginator import Paginator 
>>> from django.contrib.auth.models import User
# let's first create some users to play with
>>> for i in range(27):
...     User.objects.create(username='test_{}'.format(i))
... 
<User: test_0>
<User: test_1>
<User: test_2>
# ...
>>> users = User.objects.all()
>>> users[:5]
<QuerySet [<User: rouizi>, <User: test_0>, <User: test_1>, <User: test_2>, <User: test_3>]>
>>> p = Paginator(users, 5) # 5 users per page
>>> p.count       # Total number of objects
28
>>> p.num_pages   # Number of pages needed to split all the users
6                 # We will have 5 pages with 5 users each and 1 page with 3 users
>>> p.page_range  # Range of the pages
range(1, 7)
>>> p.page(1)     # Page object for the first page
<Page 1 of 6>
>>> p.page(1).object_list # Returns the content of the first page
[<User: rouizi>, <User: test_0>, <User: test_1>, <User: test_2>, <User: test_3>]
>>> p.page(6).object_list # Content for the last page
[<User: test_24>, <User: test_25>, <User: test_26>]
>>> p.page(6).has_next() 
False      # There is no page after the 6th page
>>> p.page(6).has_previous()
True     # The 6th page has a previous page
>>> p.page(6).next_page_number() # The number of the page after the 6th page
Traceback (most recent call last):
# ...
django.core.paginator.EmptyPage: That page contains no results
>>> p.page(6).previous_page_number() # The number of the page before the 6th page
5

The Paginator.page() method throws two types of exceptions:

  • PageNotAnInteger: Raised when page() is given a value that isn’t an integer.
  • EmptyPage: Raised when page() is given a number but the corresponding page contains no results (Note that the page numbering starts at 1, and not at 0).

Both classes are subclasses of InvalidPage, so if we want to return the same behavior for the two exceptions we can simply use the InvalidPage exception.

Here is a quick example:

def list_users(request):
    page = request.GET.get('page', 1)
    users = User.objects.all()
    paginator = Paginator(users, 5) # 5 users per page
        
    try:
        users = paginator.page(page)
    except InvalidPage:
        # if the page contains no results (EmptyPage exception) or
        # the page number is not an integer (PageNotAnInteger exception)
        # return the first page
        users = paginator.page(1)

Before finishing this section, let's see some examples for the EmptyPage and PageNotAnInteger exceptions:

>>> p.page(0)
Traceback (most recent call last):
# ...
django.core.paginator.EmptyPage: That page number is less than 1
>>> p.page(999)
Traceback (most recent call last):
# ...
django.core.paginator.EmptyPage: That page contains no results
>>> p.page('zed')
Traceback (most recent call last):
# ...
django.core.paginator.PageNotAnInteger: That page number is not an integer

Implementation

Now that you have a better understanding of how the Pagination class works, let's see now how to use it in a real project.

First, edit the urls.py file like this:

# app/urls.py
from django.contrib import admin
from django.urls import path

from core.views import list_users

urlpatterns = [
    path('admin/', admin.site.urls),
    path('list_users/<int:page>/', list_users, name='list_users')
]

To be able to know what page to show, our view needs a page parameter. We have 2 ways to do this:

  • pass the page argument to the request via the GET parameter. We can then retrieve it in the view like this: page = request.GET.get('page').
  • edit the view to add page as an extra parameter (like we did above).

Now let's create the view function:

# core/views.py
from django.shortcuts import render
from django.contrib.auth.models import User              
from django.core.paginator import Paginator, EmptyPage


def list_users(request, page=1):
    users = User.objects.all()
    paginator = Paginator(users, 5) # 5 users per page
    
    # We don't need to handle the case where the `page` parameter
    # is not an integer because our URL only accepts integers
    try:
        users = paginator.page(page)
    except EmptyPage:
        # if we exceed the page limit we return the last page 
        users = paginator.page(paginator.num_pages)
            
    return render(request, 'home.html', {'users': users})

 Create a file home.html inside the templates directory and add this code:

<!-- templates/home.html -->
<!DOCTYPE html>
<html>
<head>
  <!-- Required meta tags -->
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <!-- Bootstrap CSS -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css" integrity="sha384-B0vP5xmATw1+K9KRQjQERJvTumQW0nPEzvF6L/Z6nronJ3oUOFUFpCjEUQouq2+l" crossorigin="anonymous">
  <title>Pagination system</title>
</head>
<body>

  <div class='container'>
    <div class='mt-5 pt-5 ml-5 pl-5'>
      <p><strong>Username:</strong></p>
      {% for user in users %}
      <p>{{ user.username }}</p>
      {% endfor %}
    </div>

</body>
</html>

Run the server and let's see how this works:

Pagination page 1

Pagination page 2

Pagination page empty

Pagination in the Template

In this last section, we will add a way to navigate between pages without the need to play with the URL.

So as not to get lost in the details, let's create a super simple navigation with Previous and Next links:

<!-- templates/home.html -->
<div class='container'>
  <div class='mt-5 pt-5 ml-5 pl-5'>
    <p><strong>Username:</strong></p>
    {% for user in users %}
    <p>{{ user.username }}</p>
    {% endfor %}
  </div>

  <!-- Pagination -->
  <div class='pagination justify-content-center'>
    {% if users.has_previous %}
      <a href='{% url "list_users" users.previous_page_number %}'>Previous </a>
    {% endif %}

    <span class='mx-4'>
      Page {{ users.number }} of {{ users.paginator.num_pages }}
    </span>

    {% if users.has_next %}
      <a href='{% url "list_users" users.next_page_number %}' > Next</a>
    {% endif %}
  </div>
  <!-- END Pagination -->
</div>

 

Simple Pagination

We can customize it to display the number for all the pages:

<!-- templates/home.html -->
<!-- Pagination -->
<ul class='pagination justify-content-center'>
  {% if users.has_previous %}
    <li class="mx-1"><a href='{% url "list_users" users.previous_page_number %}'>Previous</a></li>
  {% else %}
    <li class='mx-1 disabled'><span >Previous</span></li>
  {% endif %}

  {% for i in users.paginator.page_range %}
    {% if users.number == i %}
      <li class='active mx-1'><span>{{ i }}</span></li>
      {% else %}
      <li class="mx-1"><a href='{% url "list_users" i %}'>{{ i }}</a></li>
    {% endif %}
  {% endfor %}
  
  {% if users.has_next %}
    <li class="mx-1"><a href='{% url "list_users" users.next_page_number %}' >Next</a></li>
  {% else %}
    <li class="mx-1"><span class='disabled'>Next</span></li> 
  {% endif %}
</ul>
<!-- END Pagination -->

Advanced Pagination

The above implementation is pretty good, but if we had a lot of users, the whole number of pages will be displayed. So what we need to do is display only a few pages after and before the current page.

Here is how to do it:

<!-- templates/home.html -->
<!-- Pagination -->
<nav>
  <ul class="pagination justify-content-center">
      <li class="page-item {% if not users.has_previous %} disabled {% endif %}">
          <a class="page-link" href="{% if users.has_previous %} {% url 'list_users' users.previous_page_number %}{% endif %} ">Previous</a>
      </li>

      {% if users.number|add:'-1' > 1 %}
          <li class="page-item disabled"><a class="page-link">&hellip;</a></li>
      {% endif %}

      {% for i in users.paginator.page_range %}
          {% if users.number == i %}
              <li class="active page-item disabled"><a class="page-link" href="#">{{ i }}</a></li>
          {% elif i > users.number|add:'-2' and i < users.number|add:'2' %}
              <li class="page-item"><a class="page-link" href="{% url 'list_users' i %}">{{ i }}</a></li>
          {% endif %}
      {% endfor %}

      {% if users.paginator.num_pages > users.number|add:'1' %}
          <li class="page-item disabled"><a class="page-link" href="#">&hellip;</a></li>
      {% endif %}

      <li class="page-item {% if not users.has_next %} disabled {% endif %}">
          <a class="page-link" href="{% if users.has_next %} {% url 'list_users' users.next_page_number %} {% endif %}">Next</a>
      </li>
  </ul>
</nav>  
<!-- END Pagination -->

Full Pagination

Summary

In this tutorial I showed you how Django's Pagination class works and how to use its methods in a view and templates to have a fully working pagination system. We also saw how to handle the edge cases with exceptions.

The final code is available on GitHub at: https://github.com/Rouizi/django-pagination-with-fbc/

Comments 5
Avatar Dane Miller said
Hi there Rouizi Yacine. Firstly let me say thanks for this wonderful tutorial. I have searched all over the internet for a pagination tutorial and yours is the best explained and nicest to implement. Not only this, but I like how you explained how pagination works. 
Apart from this I have a problem now. I created a custom form to filter my queryset which works find. But when I click on a new page the filter comes off and the pagination does not work. Apparently the pagination does not recognize the form filters once I click to move to another page. Actually the page reloads with the filtered fields showing blank. 

Any help will be greatly appreciated. My code is below: 
@login_required
def BBSO_RecordsListViewUnclosed(request, page=1):

    """
        create a listview of OPEN BBSO_Records Data and also a form filter to filter the records
        by the main BBSO fields. It also allows for the downloading of CSV files. 
    """

    searchform = SearchSOSListing(request.POST or None)

    # qs =  BBSO_Records.objects.select_related('observer_name_ID','observer_department_ID','site_location_ID').order_by('-id')
    # qs =  BBSO_Records.objects.filter(bbso_record_actions__date_closed__isnull=False).order_by('-id').distinct() # working versions before I tried other things 
    qs =  BBSO_Record_Actions.objects.select_related('bbso_record_ID','assigned_to').filter(date_closed__isnull=True).order_by('-date_created')

    print()
    print(f'------------{qs.query}')
    print()

    # get the controls from the from and place it into variables . 
    record_num = request.POST.get('record_num')
    date_from = request.POST.get('date_from')
    date_to = request.POST.get('date_to')
    dept_name = request.POST.get('dept_name')
    employee = request.POST.get('employee')
    severity = request.POST.get('severity')
    sos_title = request.POST.get('sos_title')    
    site_location = request.POST.get('site_location')    
    csv_export = request.POST.get('csv_export')

    # check for empty strings '' and none by using the function is_valid_queryparam. 
    if is_valid_queryparam(record_num):
        qs = qs.filter(bbso_record_ID_id=record_num)

    if is_valid_queryparam(date_from):
        qs = qs.filter(date_created__gte=date_from)

    if is_valid_queryparam(date_to):
        qs = qs.filter(date_created__lte=date_to)

    if is_valid_queryparam(sos_title):
        qs = qs.filter(bbso_record_ID__bbso_title__icontains=sos_title)

    if is_valid_queryparam(severity):
        if severity != 'ALL': # if it is not equal to all then return the selected value. 
            qs = qs.filter(bbso_record_ID__severity_level__iexact=severity)

    if is_valid_queryparam(dept_name):
        if dept_name != 'ALL': # if it is not equal to all then return the selected value. 
            # qs = qs.filter(observer_department_ID__department_name__iexact=dept_name)
            qs = qs.filter(assigned_to__dept__department_name=dept_name)

    if is_valid_queryparam(site_location):
        if site_location != 'ALL': # if it is not equal to all then return the selected value. 
            qs = qs.filter(bbso_record_ID__site_location_ID__location_name__iexact=site_location)

    if is_valid_queryparam(employee):
        if employee != 'ALL': # if it is not equal to all then return the selected value. 
            qs = qs.filter(assigned_to__username__iexact=employee)

    if csv_export == 'on': # if the export button is clicked then export to csv. 

        # Create the HttpResponse object with the appropriate CSV header.
        response = HttpResponse(content_type='text/csv') 
        filename = f'SOSListing_Unclosed{str(date.today())}.csv' #  tells the browsers what do do with the response. 
        response['Content-Disposition'] = f'attachment; filename="{filename}"' #  tells the browsers what do do with the response. 
                                
        writer = csv.writer(response)
        # https://djangotalk.blogspot.com/2013/09/re-iterating-over-queryset.html

        headers = ['Record ID', 'Severity', 'Dated', 'Created By','SOS Title', 'Details of Observation','Observer Dept','Site Location']
        writer.writerow(headers) # write the header row.  
                
        for row in qs:# loop and pass the column data  
            #  create the rows of data to export and remove html tags from Obserer Details field. 
            writer.writerow([row.id, row.severity_level, row.date_recorded, row.observer_name_ID, row.bbso_title, 
                             cleanhtml(row.details_of_observation), row.observer_department_ID, row.site_location_ID])

        return response 

    # page 34 of the Django 3 by Example 
    # https://dontrepeatyourself.org/post/django-pagination-with-function-based-view/
    # I built a hybrid 
    paginator = Paginator(qs, 15) # rows per page of the filtered queryset. 
    # page = request.GET.get('page')

    try:
        page_qs = paginator.page(page)
    except EmptyPage:       
        page_qs = paginator.page(paginator.num_pages) # If page is out of range, show last existing page.

    context = {
        'queryset':qs, # this is the full filtered queryset 
        'searchform': searchform,
        'page': page,
        'page_qs': page_qs, # this is were we break up the queryset in pages. 
    } 

    return render(request, "sos/bbso_records/bbso_record_list_unclosed_actions.html", context)

June 14, 2021, 3:37 p.m.

Avatar Yacine said
Hi  @Dane Miller, 
I'm glad you found my post useful, and thanks for the compliment.
I am not quite sure where the problem is coming from, but I think why the filters comes off when you click on a new page because you are filtering using a POST request instead of a GET request:
# ...    
csv_export = request.POST.get('csv_export')

Here, for example, you should use request.GET.get('csv_export').
Because when you click on a new page, you are sending a GET request to the server, not a POST request.
Actually, when the user click on a filter, he is making a POST request to the server but when he clicks on a new page, he is making a GET request. 
So what you need to do is: the first time the user click on a filter you get that filter on the server from a POST request (if request.method == 'POST'), and create a new variable ('filter' for example), assign to it the filter the user chosen, and send it back to the template. 
So the next time the user use the pagination to change a page (GET request this time) you'll have the 'filter' in the GET request.

I faced this problem once in my project, which you can check how I did it here: https://github.com/Rouizi/OC_project11/blob/4f8e5b4765eaab9470906c14ae5e8528ceafa610/catalog/views.py#L118
Also try to see the effect on the live site on Heroku here: https://purbeurre-oc11.herokuapp.com/catalog/products/

June 14, 2021, 5:36 p.m.

Avatar Dane Miller said
Hi Yacine thanks for all your help and assistance. Once again I am indicating that you have listed a wonderful post that can be easily implemented. Cheers. 

But as I developed my app I needed another Use Case where I needed pagination with a filter. In that regard I found this post which gives good example. I am just posting it to help others. https://www.youtube.com/watch?v=dkJ3uqkdCcY

June 20, 2021, 5:02 p.m.

Avatar Yacine said
Hi @Dane, it's always a pleasure for me to help, and I am glad that you found my post useful. Thank you.
Also, thank you for sharing with us what you found, it will surely be useful for someone.

June 20, 2021, 7:41 p.m.

Avatar anonymus said
This was helpful

Nov. 23, 2021, 3:02 p.m.

Leave a comment

(Your email address will not be published)