~/TechPurAI
~/tutorials/django-from-scratch/pagination
intermediate·part 20 of 22·3 min read

Pagination: splitting a long list across pages

Updated Aug 31, 2026Python · Django

PostListView has rendered every published post on one page since part 9 — fine at five posts, a real problem at five hundred. Pagination is one attribute on a ListView, plus a small amount of template work to actually navigate between pages.

Adding paginate_by

python
# blog/views.py
class PostListView(ListView):
    model = Post
    template_name = "blog/post_list.html"
    context_object_name = "posts"
    queryset = Post.objects.filter(published=True)
    paginate_by = 5

That's the entire view-side change. ListView now returns 5 posts per page and adds two extra template variables: page_obj (the current page, with methods like .has_next()) and paginator (the overall paginator, with .num_pages).

Rendering page navigation

django
{# blog/templates/blog/post_list.html #}
{% for post in posts %}
  ...
{% endfor %}

{% if is_paginated %}
  <nav>
    {% if page_obj.has_previous %}
      <a href="?page={{ page_obj.previous_page_number }}">Previous</a>
    {% endif %}

    <span>Page {{ page_obj.number }} of {{ paginator.num_pages }}</span>

    {% if page_obj.has_next %}
      <a href="?page={{ page_obj.next_page_number }}">Next</a>
    {% endif %}
  </nav>
{% endif %}

is_paginated is True only when there's more than one page — hiding the whole nav block on a five-post blog with paginate_by = 5 until there's actually a second page to navigate to. ?page={{ page_obj.next_page_number }} works because ListView already reads a page query parameter from the URL automatically — no URL pattern change, no view code, to make ?page=2 work.

What's happening underneath

python
from django.core.paginator import Paginator

posts = Post.objects.filter(published=True)
paginator = Paginator(posts, 5)
page_obj = paginator.get_page(1)

page_obj.object_list   # the 5 Post objects for this page
page_obj.has_next()    # True/False
paginator.num_pages    # total page count

This is exactly what ListView does internally once paginate_by is set — worth seeing directly once, since Paginator is also what to reach for inside a plain function-based view that needs pagination without a generic ListView wrapping it.

Common mistake

Slicing a queryset by hand — Post.objects.all()[start:end] with manually computed offsets — instead of using Paginator. It's more code, and it's easy to get the boundary math wrong (an off-by-one that duplicates or skips a post between pages) in exactly the way Paginator already handles correctly.

FAQ

What happens if someone visits ?page=999 on a blog with only 3 pages? ListView's paginator raises a 404 for a page number past the last real page — not an empty page or an error page, a clean not-found response, which is usually the correct behavior for a page number that couldn't possibly be valid.

Can I show numbered page links (1, 2, 3) instead of just Previous/Next? Yes — paginator.page_range (or paginator.get_elided_page_range() for a version that collapses a very long range with ...) gives an iterable of page numbers to loop over in the template, building a numbered link per page instead of only Previous/Next.

Does pagination affect the database query, or just what's displayed? It affects the query — Paginator uses LIMIT/OFFSET under the hood, so only the current page's rows are actually fetched from the database, not the entire table with unwanted rows discarded afterward.

The blog itself is functionally complete. The last two parts cover what it takes to trust it, and actually put it in front of real users: automated tests, then deployment.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous19. Media files: handling image and file uploadsnext →21. Testing a Django app: TestCase and the test client