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

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 }}">&laquo; 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 &raquo;</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=3

3 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: 10

Common 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 / MethodWhat It Gives You
Paginator(qs, 10)Split a QuerySet into pages of 10
paginator.num_pagesTotal number of pages (rounded up)
paginator.page(n)A Page object for page n
page_obj.has_nextTrue if a following page exists
paginate_by = 10Turn 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

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.