Django

Pagination — the Paginator class

Rendering 10,000 rows on one page is both a terrible user experience and a genuinely slow query — Paginator solves both by slicing a queryset into pages and doing the counting/bounds-checking that would otherwise be hand-rolled every time.

Beginner

3 min read

The problem: a queryset with thousands of rows

def article_list(request):
    articles = Article.objects.all()   # could be 10, could be 100,000
    return render(request, "articles.html", {"articles": articles})

Without pagination, this either renders every single row (slow to query, slow to render, a genuinely bad page for a user to scroll) or requires hand-rolling page-number math, bounds checking for "page 0" or "page past the end," and a LIMIT/OFFSET slice on the queryset — every time, in every view that lists something. Paginator is Django's built-in answer to exactly this, used the same way regardless of what's being paginated.

Paginator: split a queryset (or any list) into pages

from django.core.paginator import Paginator
 
def article_list(request):
    articles = Article.objects.all().order_by("-created_at")
    paginator = Paginator(articles, 25)   # 25 items per page
 
    page_number = request.GET.get("page", 1)
    page_obj = paginator.get_page(page_number)
 
    return render(request, "articles.html", {"page_obj": page_obj})

Paginator(queryset, per_page) does the splitting; get_page(page_number) returns a Page object for that specific page — and critically, get_page never raises for an out-of-range page number: page 0, a negative number, or a page past the last one all quietly clamp to the nearest valid page (page 1, or the last page) instead of crashing. This is almost always the right behavior for a page a user might land on via a stale bookmark or a hand-edited URL.

The Page object in a template

{% for article in page_obj %}
  <p>{{ article.title }}</p>
{% 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>

A Page object is directly iterable — looping over page_obj yields just that page's items, the slicing already done. .has_previous/.has_next and .previous_page_number/.next_page_number are what make building Previous/Next links straightforward without any manual off-by-one arithmetic; page_obj.paginator.num_pages gives the total page count for a "Page 3 of 40" display.

Why Paginator needs an explicitly ordered queryset

# WITHOUT .order_by(), the database can return rows in a DIFFERENT order
# on each query — meaning page 2 might show rows already seen on page 1,
# or skip rows entirely, as the underlying data or query plan shifts
articles = Article.objects.all()                  # no guaranteed order
articles = Article.objects.all().order_by("-created_at")   # deterministic — REQUIRED for correct paging

Pagination works by slicing the queryset with LIMIT/OFFSET at the database level — that slice is only meaningful if the queryset has a consistent, deterministic order across repeated queries. An unordered queryset has no such guarantee (covered from a different angle in the querysets-are-lazy lesson); Django even emits a UnorderedObjectListWarning specifically for this, since paginating an unordered queryset can silently show duplicate or missing rows across pages.

ListView's built-in pagination

from django.views.generic import ListView
 
class ArticleListView(ListView):
    model = Article
    paginate_by = 25
    ordering = ["-created_at"]

Django's generic ListView (covered in the class-based views lesson) wraps Paginator automatically — setting paginate_by is enough to get the same page_obj in the template context, with no manual Paginator/get_page calls needed. This is the more common way pagination actually gets added in real Django code; the manual version above is what to reach for in a plain function-based view, or to understand what paginate_by is doing underneath.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What happens when paginator.get_page(999) is called on a paginator with only 10 pages?

2. Why does Paginator require the queryset to be explicitly ordered?

3. What does paginate_by = 25 do on a Django ListView?

4. In a template, what does page_obj.has_next tell you?