Pagination: splitting a long list across pages
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
# 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 = 5That'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
{# 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
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 countThis 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.
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.