Pagination with Paginator
Reviewed & published by Brayan K
Pagination is the technique of splitting a long list of database rows into numbered pages so a visitor sees a manageable slice at a time instead of one enormous scroll.
Part of the free Django course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll use Django's Paginator to slice a QuerySet, read the requested page from the ?page= query parameter, build next and previous links from a page object, handle bad page numbers safely, and let a ListView paginate for you with a single attribute.
What You'll Learn in This Lesson
- Create a Paginator from a QuerySet and a per-page size
- Get a single Page with paginator.page(number)
- Read the page number from the ?page= query parameter
- Use has_next, has_previous, and page_range for links
- Handle PageNotAnInteger and EmptyPage safely
- Paginate a class-based ListView with paginate_by
1 The Paginator — Slicing a List into Pages
You create a Paginator by handing it two things: the list (or QuerySet) you want to split and how many items belong on each page. The Paginator then computes the total count, the number of pages (num_pages), and a page_range you can loop over to render page-number links.
# blog/views.py
from django.core.paginator import Paginator
from django.shortcuts import render
from .models import Post
def post_list(request):
posts = Post.objects.all().order_by("-created_at") # a QuerySet
paginator = Paginator(posts, 5) # 5 posts per page
print(paginator.count) # total number of posts
print(paginator.num_pages) # how many pages that makes
print(paginator.page_range) # range(1, num_pages + 1)
page_obj = paginator.page(1) # the first page (a Page object)
return render(request, "blog/post_list.html", {"page_obj": page_obj})# Paginator does the math: given a total count of items and how many fit
# on a page, it works out how many pages exist and which items each holds.
# This plain-Python Paginator mirrors django.core.paginator.Paginator.
import math
class Paginator:
def __init__(self, object_list, per_page):
self.object_list = object_list
self.per_page = per_page
@property
def count(self):
return len(self.object_list)
@property
def num_pages(self):
if self.count == 0:
return 1
return math.ceil(self.count / self.per_page)
@property
def page_range(self):
return list(range(1, self.num_pages + 1))
def page(self, number):
if number < 1 or number > self.num_pages:
raise ValueError("EmptyPage: that page contains no results")
start = (number - 1) * self.per_page
end = start + self.per_page
return self.object_list[start:end]
items = [f"post-{i}" for i in range(1, 24)] # 23 posts
paginator = Paginator(items, per_page=5)
print("count:", paginator.count)
print("num_pages:", paginator.num_pages)
print("page_range:", paginator.page_range)
print("page 1:", paginator.page(1))
print("page 5:", paginator.page(5))
# EXPECTED:
# count: 23
# num_pages: 5
# page_range: [1, 2, 3, 4, 5]
# page 1: ['post-1', 'post-2', 'post-3', 'post-4', 'post-5']
# page 5: ['post-21', 'post-22', 'post-23']
# ✅ Expected output:
# count: 23
# num_pages: 5
# page_range: [1, 2, 3, 4, 5]
# page 1: ['post-1', 'post-2', 'post-3', 'post-4', 'post-5']
# page 5: ['post-21', 'post-22', 'post-23']2 The Page Object — has_next, has_previous, and Links
paginator.page(number) returns a Page object. In a template you iterate it directly to render the rows, and you ask it questions to build navigation: page_obj.has_next, page_obj.has_previous, and the neighbouring numbers next_page_number and previous_page_number.
<!-- blog/templates/blog/post_list.html -->
{% for post in page_obj %}
<article>{{ post.title }}</article>
{% endfor %}
<nav class="pagination">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}">« Prev</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 %}
</nav># A "page object" knows where it sits in the sequence: its number, whether
# there is a next/previous page, and what those neighbouring numbers are.
class Page:
def __init__(self, number, num_pages):
self.number = number
self.num_pages = num_pages
def has_next(self):
return self.number < self.num_pages
def has_previous(self):
return self.number > 1
def next_page_number(self):
return self.number + 1
def previous_page_number(self):
return self.number - 1
# Pretend we asked for page 2 of 5
page = Page(number=2, num_pages=5)
print("current page:", page.number, "of", page.num_pages)
print("has_previous:", page.has_previous())
print("has_next:", page.has_next())
print("previous link -> ?page=" + str(page.previous_page_number()))
print("next link -> ?page=" + str(page.next_page_number()))
# EXPECTED:
# current page: 2 of 5
# has_previous: True
# has_next: True
# previous link -> ?page=1
# next link -> ?page=3
# ✅ Expected output:
# current page: 2 of 5
# has_previous: True
# has_next: True
# previous link -> ?page=1
# next link -> ?page=33 Handling Bad Pages and Paginating a ListView
The ?page= value comes from the URL, so a user (or a crawler) can send anything: ?page=abc, ?page=999, or nothing at all. Django gives you two exceptions to catch — PageNotAnInteger for non-numeric input and EmptyPage for out-of-range numbers — and the convention is to fall back to the first or last page.
# blog/views.py
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
def post_list(request):
posts = Post.objects.all().order_by("-created_at")
paginator = Paginator(posts, 5)
page_number = request.GET.get("page") # text from ?page=, or None
try:
page_obj = paginator.page(page_number)
except PageNotAnInteger:
page_obj = paginator.page(1) # bad input -> first page
except EmptyPage:
page_obj = paginator.page(paginator.num_pages) # too big -> last page
return render(request, "blog/post_list.html", {"page_obj": page_obj})A class-based ListView makes this even shorter. Set paginate_by and Django builds the Paginator, reads ?page=, and adds page_obj and is_paginated to the context automatically.
# blog/views.py
from django.views.generic import ListView
from .models import Post
class PostListView(ListView):
model = Post
paginate_by = 5 # that single line turns pagination on
template_name = "blog/post_list.html"
context_object_name = "posts"
ordering = ["-created_at"]# Django raises PageNotAnInteger if ?page= isn't a number, and EmptyPage if
# the number is out of range. The usual pattern: fall back to page 1 for bad
# input, and to the last page for too-large numbers.
class PageNotAnInteger(Exception):
pass
class EmptyPage(Exception):
pass
def get_page(raw_value, num_pages):
try:
number = int(raw_value)
except (TypeError, ValueError):
raise PageNotAnInteger("not an integer")
if number < 1 or number > num_pages:
raise EmptyPage("out of range")
return number
def safe_page(raw_value, num_pages):
try:
return get_page(raw_value, num_pages)
except PageNotAnInteger:
return 1 # bad input -> first page
except EmptyPage:
return num_pages # too big -> last page
num_pages = 5
for raw in ["3", "abc", "99", None]:
print(repr(raw), "->", safe_page(raw, num_pages))
# EXPECTED:
# '3' -> 3
# 'abc' -> 1
# '99' -> 5
# None -> 1
# ✅ Expected output:
# '3' -> 3
# 'abc' -> 1
# '99' -> 5
# None -> 1🎯 Your Turn: Count the Pages
A ListView with paginate_by = 10 splits 95 items into pages of ten. Fill in the blank with the division operator so the count rounds up correctly.
# 🎯 YOUR TURN: a ListView with paginate_by = 10 splits items into pages of 10.
# Replace ___ so num_pages divides 95 items into pages of 10, rounding UP.
import math
total_items = 95
per_page = 10
num_pages = math.ceil(total_items ___ per_page)
print("num_pages:", num_pages)
# EXPECTED OUTPUT:
# num_pages: 10Common Errors (And How to Fix Them)
❌ EmptyPage: That page contains no results
You passed a page number larger than num_pages (often from ?page=999) straight to paginator.page().
✅ Fix: wrap the call in try/except EmptyPage and fall back to paginator.page(paginator.num_pages).
❌ PageNotAnInteger when ?page= is missing or text
request.GET.get("page") returns None or a string like "abc", which paginator.page() can't use.
✅ Fix: catch PageNotAnInteger and fall back to paginator.page(1).
❌ Next/Prev links break the filters or sort order
A link of just ?page=2 drops any other query parameters like ?q=django.
✅ Fix: preserve the rest of the query string, e.g. build links that keep request.GET and only swap the page value.
📋 Quick Reference
| Attribute / Method | What It Gives You |
|---|---|
| Paginator(qs, 10) | Split a QuerySet into pages of 10 |
| paginator.num_pages | Total number of pages (rounded up) |
| paginator.page(n) | A Page object for page n |
| page_obj.has_next | True if a following page exists |
| paginate_by = 10 | Turn on pagination in a ListView |
🏆 Mini Challenge: Render a Pagination Bar
Write a helper that prints a full pagination bar — a Prev link, every page number with the current one highlighted, and a Next link — for any total, page size, and current page.
# 🏆 Mini Challenge: build the full set of pagination links for a page.
# Given a total count, a page size, and the current page, print the
# previous link, the page numbers, and the next link.
import math
def pagination_bar(total, per_page, current):
num_pages = max(1, math.ceil(total / per_page))
current = max(1, min(current, num_pages))
parts = []
if current > 1:
parts.append("Prev(?page=" + str(current - 1) + ")")
for n in range(1, num_pages + 1):
parts.append("[" + str(n) + "]" if n == current else str(n))
if current < num_pages:
parts.append("Next(?page=" + str(current + 1) + ")")
return " ".join(parts)
print(pagination_bar(total=23, per_page=5, current=2))
print(pagination_bar(total=23, per_page=5, current=1))
print(pagination_bar(total=23, per_page=5, current=5))
# EXPECTED:
# Prev(?page=1) 1 [2] 3 4 5 Next(?page=3)
# [1] 2 3 4 5 Next(?page=2)
# Prev(?page=4) 1 2 3 4 [5]❓ Frequently Asked Questions
Lesson complete — your lists scale to any size!
You can build a Paginator, pull a single Page with paginator.page(), read ?page= from the URL, render Next/Prev links from has_next and has_previous, survive bad page numbers with EmptyPage and PageNotAnInteger, and let a ListView do it all with paginate_by.
Practice quiz
Which class splits a list or QuerySet into numbered pages?
- Paginator
- PageSplitter
- QuerySet
- ListView
Answer: Paginator. django.core.paginator.Paginator takes an object list and a per-page count.
How do you create a Paginator with 5 posts per page?
- Paginator(5, posts)
- Paginator(posts, 5)
- Paginator(posts).limit(5)
- Paginator(posts, page=5)
Answer: Paginator(posts, 5). The second positional argument is the number of items per page, e.g. Paginator(posts, 5).
Which attribute holds the total number of pages?
- paginator.count
- paginator.page_count
- paginator.total
- paginator.num_pages
Answer: paginator.num_pages. paginator.num_pages is the total number of pages; paginator.count is the total number of objects.
Which method returns a Page object for a given page number?
- paginator.get(n)
- paginator.page(n)
- paginator.at(n)
- paginator.fetch(n)
Answer: paginator.page(n). paginator.page(n) returns the Page object for page number n.
Which exception is raised when the requested page number is out of range?
- PageNotAnInteger
- IndexError
- EmptyPage
- DoesNotExist
Answer: EmptyPage. paginator.page() raises EmptyPage when the page number is too large or too small.
Which exception is raised when ?page= is not a number?
- EmptyPage
- PageNotAnInteger
- ValueError
- TypeError
Answer: PageNotAnInteger. paginator.page() raises PageNotAnInteger when the value cannot be read as an integer.
Which Page attribute is True when a following page exists?
- page_obj.has_next
- page_obj.more
- page_obj.next_exists
- page_obj.is_last
Answer: page_obj.has_next. page_obj.has_next is True when there is a page after the current one.
Where does the requested page number usually come from in a view?
- request.POST['page']
- request.session['page']
- request.GET.get('page')
- request.path
Answer: request.GET.get('page'). The page number arrives in the query string ?page=, read via request.GET.get('page').
Which ListView attribute turns pagination on?
- page_size
- per_page
- pagination
- paginate_by
Answer: paginate_by. Setting paginate_by on a ListView makes Django build the Paginator automatically.
How is num_pages computed from count and per_page?
- count divided down (floor)
- count minus per_page
- count divided and rounded up (ceil)
- count times per_page
Answer: count divided and rounded up (ceil). The page count is the total divided by per-page size, rounded up: ceil(count / per_page).
Continue this course
- Previous: Checkpoint: Models & the ORM
- Next: The Sessions Framework — Store per-user data across requests with the session framework
- Quick reference: Django cheat sheet
Frequently asked questions
What is the difference between the Paginator and a Page object?
The Paginator wraps the whole list (or QuerySet) and knows global facts: the total count, how many items fit per page, and how many pages exist. A Page object represents one slice of that list — the rows for a single page — and knows its own number plus whether a next or previous page exists. You create a Paginator once, then call paginator.page(number) to get a Page for the page the user requested.
How do I read the requested page number from the URL?
Django reads it from the query string, so a link like ?page=3 puts the value in request.GET. In a function-based view you write page_number = request.GET.get('page'); in a class-based ListView the same ?page= parameter is handled for you automatically. Always treat the value as untrusted text — it might be missing, non-numeric, or out of range — and handle PageNotAnInteger and EmptyPage.
How do I add pagination to a class-based ListView?
Set the paginate_by attribute on the ListView to the number of items you want per page, for example paginate_by = 10. Django then builds the Paginator for you and adds page_obj, paginator, and is_paginated to the template context. You do not need to create the Paginator yourself or read ?page= manually — the generic view handles all of it.
Why does Paginator only fetch one page of rows from the database?
Paginator works with QuerySets, which are lazy. When you ask for paginator.page(2) Django adds LIMIT and OFFSET to the SQL so the database returns only the rows for that page, not the entire table. That is why pagination scales: a table with a million rows still serves a 10-row page quickly, because the database never loads everything into memory at once.