Python Django Pagination: Paginator and ListView
python django pagination: Implement Django pagination with Paginator and ListView, including template controls, edge cases, and performance notes.
When a Django view returns a large QuerySet, rendering every row in a single template response becomes slow and unwieldy. The standard solution is python django pagination, which splits results into pages. Django provides two primary tools: the Paginator class for function-based views and the paginate_by attribute on ListView for class-based views. Both rely on the same underlying mechanism and produce a Page object that templates can iterate over.
Understanding Django's Paginator Class
The Paginator class lives in django.core.paginator. It takes an object list—typically a QuerySet—and a page size, then exposes methods to retrieve specific pages. The core behavior is that it does not load all objects into memory at once. Instead, it issues a COUNT query and then uses LIMIT and OFFSET clauses to fetch only the rows for the requested page.
from django.core.paginator import Paginator items = Item.objects.all() paginator = Paginator(items, 10) # 10 items per page page_number = request.GET.get('page') page_obj = paginator.get_page(page_number)
get_page() handles invalid page numbers gracefully. If the page is not an integer, it returns the first page. If the page is out of range, it returns the last page. This is convenient for user-facing views where you do not want to raise a 404 for a malformed query parameter.
Paginating a QuerySet in a Function-Based View
In a function-based view, you create a Paginator from the QuerySet, retrieve the current page, and pass it to the template context. The view must read the page query parameter and pass it to get_page().
from django.shortcuts import render from django.core.paginator import Paginator from .models import Item def item_list(request): items = Item.objects.all().order_by('-created_at') paginator = Paginator(items, 20) page_number = request.GET.get('page') page_obj = paginator.get_page(page_number) return render(request, 'items/item_list.html', {'page_obj': page_obj})
The page_obj is a Page instance. It has .object_list containing the items for the current page, and it exposes properties like .has_previous, .has_next, .previous_page_number, and .next_page_number. These are used in the template to build navigation controls.
Using ListView with paginate_by
Class-based views reduce boilerplate. ListView has a paginate_by attribute that automatically paginates the queryset. You only need to set the attribute and provide a template context variable name.
from django.views.generic import ListView from .models import Item class ItemListView(ListView): model = Item template_name = 'items/item_list.html' context_object_name = 'items' paginate_by = 20
By default, ListView passes a page_obj to the template, just like the function-based view. The paginate_by attribute triggers the same Paginator logic internally. You can override get_queryset() to filter or order the objects, and the pagination will apply to the resulting QuerySet.
Rendering Pagination Controls in Templates
The template receives page_obj. A common pattern is to display the current page's items and then render a set of page links. Django's template language does not have a built-in pagination widget, so you build the controls manually.
{% for item in page_obj %} <div class="item">{{ item.name }}</div> {% endfor %} <div class="pagination"> {% if page_obj.has_previous %} <a href="?page={{ page_obj.previous_page_number }}">Previous</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</a> {% endif %} </div>
This basic control works, but it only shows previous and next links. For a more complete pagination bar with page numbers, you can iterate over page_obj.paginator.page_range. The page_range is a range object from 1 to num_pages. You can conditionally highlight the current page.
{% for num in page_obj.paginator.page_range %} {% if num == page_obj.number %} <span class="current">{{ num }}</span> {% else %} <a href="?page={{ num }}">{{ num }}</a> {% endif %} {% endfor %}
Be careful with very large page counts. Rendering every page number can produce a huge list. Many sites use an ellipsis pattern, but that requires custom template logic or a template filter.
Handling Invalid Page Numbers and Empty Pages
get_page() in a function-based view already handles invalid input, but ListView behaves slightly differently. If you pass a page number that is out of range, ListView raises a Http404 exception. This is intentional for class-based views because they assume a page parameter that does not exist indicates a broken link. If you want to mimic the lenient behavior of get_page(), you can override get() or paginate_queryset() in your ListView.
For empty pages—when a page exists but has no objects because the queryset is empty—get_page() returns an empty Page object. The template should handle that case by checking page_obj.object_list. If you are using ListView, an empty queryset still produces a page with no items, and the template must not assume there is at least one object.
A common mistake is to use page_obj.paginator.count to display the total number of items. This is correct, but it triggers a separate COUNT query. If you already have the queryset, you can avoid the extra query by using len(page_obj.object_list) for the current page count, but that defeats the purpose of pagination for large datasets.
Performance Considerations for Large QuerySets
The Paginator class is efficient because it does not load all rows into memory. It executes two queries for each page request: one COUNT to determine the total number of objects, and one SELECT with LIMIT and OFFSET to fetch the current page. The COUNT query is fast if the database has an appropriate index on the filtered columns. The OFFSET approach, however, becomes slower as the page number increases because the database must scan and discard the preceding rows. For very deep pagination, consider keyset pagination (also called "seek method") using WHERE id > last_id instead of OFFSET. Django does not provide this out of the box, but you can implement it manually.
Another performance concern is ordering. The QuerySet passed to the Paginator must have a deterministic order. If you do not call order_by(), the database may return rows in an unpredictable order, and pagination will appear inconsistent across requests. Always add an explicit ordering, either in the model's Meta class or in the view's get_queryset().
Custom Pagination and Alternative Approaches
While the built-in Paginator covers most needs, some applications require custom behavior. For example, you might want to paginate by a cursor instead of a page number, or you might need to combine pagination with filtering. Django's Paginator works with any iterable, but it is optimized for QuerySets. If you need to paginate a list that is already in memory, you can pass the list directly, but the COUNT operation will be a Python len() call, and slicing will be done in memory.
For more control, you can subclass Paginator and override page() or validate_number(). This is useful when you want to change the default behavior for invalid page numbers. For instance, you might want to raise a 404 for any invalid page instead of falling back to the first or last page.
from django.core.paginator import Paginator class StrictPaginator(Paginator): def validate_number(self, number): try: return super().validate_number(number) except (TypeError, ValueError): raise Http404("Invalid page number")
This custom paginator can then be used in a function-based view. In a ListView, you can override get_paginator() to return your custom class.
The choice between function-based and class-based views for pagination often comes down to team preference and the complexity of the view. For simple list pages, ListView is concise. For views that need custom query handling or multiple paginated sections, a function-based view with explicit Paginator usage gives you more direct control. Both approaches produce the same Page object, so the template code remains identical.