ZetCode

Django pagination

last modified November 13, 2026 Python 3.15

A page that lists thousands of rows is slow to build, slow to download, and hard to read. Pagination splits the rows into pages and shows one page together with the links to the others. Django has a class for it, Paginator, a mixin in the generic list views, and a template tag that builds the links. In this article we use Paginator directly, handle bad page numbers, write the navigation with an elided page list that keeps the search term, let ListView do the work, and finally implement keyset pagination, which is faster for large tables. The examples use Django 6.1 and Python 3.15.

The example project

The project is created with uv; the steps are explained in Django introduction. The commands below create a project directory, install Django, generate the project with the configuration package config, start the app library, and create the folder demos for demo scripts.

$ mkdir pagination
$ cd pagination
$ uv init --bare
$ uv add django
$ uv run django-admin startproject config .
$ uv run python manage.py startapp library
$ mkdir demos

The trailing dot in startproject config . creates manage.py and the config package in the current directory instead of one level deeper. A new app is ignored by Django until it is listed in INSTALLED_APPS, so we add the app to the list in config/settings.py; the other entries are generated by startproject.

config/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'library',
]

The listings in this article are of two kinds. A listing with a solid blue border and the label Project file is a file of the application: models, views, templates, tests, and settings. A listing with a dashed amber border and the label Demo script is a small helper in the demos folder; it sets up Django, runs a few lines of code, and prints the results, so that we can watch the behaviour. Demo scripts are not part of the application; we run one from the project directory with uv run python -m demos.name.

The app library has one model, a book with a title, an author, and a year. The model has no Meta.ordering, because the order of the pages is chosen in the views.

library/models.py
from django.db import models


class Book(models.Model):
    title = models.CharField(max_length=100)
    author = models.CharField(max_length=60)
    year = models.PositiveSmallIntegerField()

    def __str__(self):
        return self.title

We create and apply the migration. The demo script seed.py then fills the table with 53 books named Book 001 to Book 053; the number is chosen so that the last page of ten books has three of them.

$ uv run python manage.py makemigrations library
No changes detected in app 'library'
$ uv run python manage.py migrate | tail -n 2
Running migrations:
  No migrations to apply.
demos/seed.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from library.models import Book

AUTHORS = ['Alice Novak', 'Boris Hall', 'Clara Stone', 'David Kerr']

Book.objects.all().delete()
Book.objects.bulk_create(
    Book(title=f'Book {number:03d}', author=AUTHORS[number % 4], year=1990 + number % 30)
    for number in range(1, 54)
)
print('books:', Book.objects.count())

The script deletes the existing rows first, so it can be run again.

$ uv run python -m demos.seed
books: 53

The Paginator class

Paginator(object_list, per_page) accepts a list, a tuple, a range, or a QuerySet. Its attributes count, num_pages, and page_range describe the pages; the method page(number) returns a Page with the objects of that page. A Page is a sequence, so we can iterate it and take its len(), and it knows has_next(), has_previous(), start_index(), and end_index(), the 1-based positions of its first and last object in the whole list. The pages are numbered from 1.

With a QuerySet the paginator runs two queries: SELECT COUNT(*) to learn the number of pages, and a SELECT with LIMIT and OFFSET for the requested page. The queries are lazy, so nothing is executed until the first attribute that needs the data. The ordering is essential: without ORDER BY the database may return the rows in any order, and a row can appear on two pages or on none, so Django warns about an unordered QuerySet.

The script below shows the attributes, the SQL, the exceptions, and the helper methods. page() is strict: it raises PageNotAnInteger or EmptyPage for a wrong number. get_page() is forgiving, which is what a web view wants: a value that is not a number gives the first page, and a number out of range gives the last page. The argument orphans merges a short last page into the previous one. The method get_elided_page_range() yields the page numbers around the current page, with Paginator.ELLIPSIS in place of the omitted ones.

demos/paginator.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

import warnings

from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db import connection
from django.test.utils import CaptureQueriesContext

from library.models import Book

books = Book.objects.order_by('title')
paginator = Paginator(books, 10)
print('count:', paginator.count, '| pages:', paginator.num_pages, '| range:', paginator.page_range)

page = paginator.page(2)
titles = [book.title for book in page]
print('page 2:', titles[0], '...', titles[-1])
print(f'  number={page.number} has_previous={page.has_previous()} has_next={page.has_next()}')
print(f'  start_index={page.start_index()} end_index={page.end_index()}')

with CaptureQueriesContext(connection) as queries:
    fresh = Paginator(books, 10)
    list(fresh.page(2))
for query in queries.captured_queries:
    sql = query['sql']
    if not sql.startswith(('BEGIN', 'COMMIT')):
        print('  SQL:', sql if sql.startswith('SELECT COUNT') else '... ' + sql[sql.index('ORDER BY'):])

last = paginator.page(paginator.num_pages)
print('last page:', len(last), 'books, has_next =', last.has_next())
print()

for number in (0, 'x', 99):
    try:
        paginator.page(number)
    except (EmptyPage, PageNotAnInteger) as error:
        print(f'page({number!r}): {type(error).__name__}: {error}')
for number in (None, 'x', 0, 99):
    print(f'get_page({number!r}) -> page {paginator.get_page(number).number}')
print()

orphans = Paginator(books, 10, orphans=3)
print('orphans=3: pages =', orphans.num_pages, '| last page has', len(orphans.page(orphans.num_pages)), 'books')
many = Paginator(range(1, 181), 10)
print('page 9 of 18:', list(many.get_elided_page_range(9, on_each_side=2, on_ends=1)))
print('page 1 of 18:', list(many.get_elided_page_range(1, on_each_side=2, on_ends=1)))
print()

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter('always')
    Paginator(Book.objects.all(), 10).page(1)
print(caught[0].category.__name__ + ':', caught[0].message)

The count query and the page query are separate. With 53 books and ten per page there are six pages, the last of which has three books. The page 0 is rejected by page() but get_page(0) returns the last page, like any other out-of-range number, while None and a string give the first page. With orphans=3 the three books of the last page join the fifth page, which then has thirteen books. The elided range keeps the first and the last page, and a window of two pages on each side of the current one.

$ uv run python -m demos.paginator
count: 53 | pages: 6 | range: range(1, 7)
page 2: Book 011 ... Book 020
  number=2 has_previous=True has_next=True
  start_index=11 end_index=20
  SQL: SELECT COUNT(*) AS "__count" FROM "library_book"
  SQL: ... ORDER BY "library_book"."title" ASC LIMIT 10 OFFSET 10
last page: 3 books, has_next = False

page(0): EmptyPage: That page number is less than 1
page('x'): PageNotAnInteger: That page number is not an integer
page(99): EmptyPage: That page contains no results
get_page(None) -> page 1
get_page('x') -> page 1
get_page(0) -> page 6
get_page(99) -> page 6

orphans=3: pages = 5 | last page has 13 books
page 9 of 18: [1, '…', 7, 8, 9, 10, 11, '…', 18]
page 1 of 18: [1, 2, 3, '…', 18]

UnorderedObjectListWarning: Pagination may yield inconsistent results with an unordered object_list: <class 'library.models.Book'> QuerySet.

Pagination in a view

The function view reads the number from the query string, calls get_page(), and passes the page and the elided range to the template. The search term q filters the QuerySet before it is paginated; the helper filtered() is shared with the class-based view that follows. Note that the search is applied first and the ordering stays the same, so the pages are consistent.

library/views.py
from django.core.paginator import Paginator
from django.http import JsonResponse
from django.shortcuts import render
from django.views.generic import ListView

from .models import Book


def filtered(request):
    books = Book.objects.order_by('title')
    query = request.GET.get('q', '').strip()
    if query:
        books = books.filter(title__icontains=query)
    return books, query


def book_list(request):
    books, query = filtered(request)
    paginator = Paginator(books, 10)
    page_obj = paginator.get_page(request.GET.get('page'))
    page_range = paginator.get_elided_page_range(page_obj.number, on_each_side=2, on_ends=1)
    return render(request, 'library/book_list.html',
                  {'page_obj': page_obj, 'page_range': page_range, 'query': query})


class BookListView(ListView):
    template_name = 'library/book_list.html'
    paginate_by = 10
    paginate_orphans = 3

    def get_queryset(self):
        books, self.query = filtered(self.request)
        return books

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['query'] = self.query
        context['page_range'] = context['paginator'].get_elided_page_range(
            context['page_obj'].number, on_each_side=2, on_ends=1)
        return context


def book_feed(request):
    size = 10
    books = Book.objects.order_by('id')
    after = request.GET.get('after')
    if after and after.isdigit():
        books = books.filter(id__gt=int(after))
    rows = list(books[:size + 1])
    has_next = len(rows) > size
    rows = rows[:size]
    return JsonResponse({
        'results': [{'id': book.id, 'title': book.title} for book in rows],
        'next': f'?after={rows[-1].id}' if has_next else None,
    })

The first view is book_list. The class BookListView and the function book_feed are described in the next sections. The URL configuration of the app gives each view a path, and the project includes it under /books/.

library/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path('', views.book_list, name='list'),
    path('cbv/', views.BookListView.as_view(), name='list-cbv'),
    path('feed/', views.book_feed, name='feed'),
]

The project URL configuration includes the URLs of the app.

config/urls.py
from django.urls import include, path

urlpatterns = [
    path('books/', include('library.urls')),
]

The templates show the page and include the navigation. The list template has a search form, and the loop over page_obj yields only the objects of the current page.

library/templates/library/book_list.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Books</title>
</head>
<body>
  <h1>Books</h1>
  <form method="get">
    <input type="search" name="q" value="{{ query }}">
    <button type="submit">Search</button>
  </form>
  <ul>
    {% for book in page_obj %}
    <li>{{ book.title }} ({{ book.author }}, {{ book.year }})</li>
    {% empty %}
    <li>No books found.</li>
    {% endfor %}
  </ul>
  {% include 'library/pagination.html' %}
</body>
</html>

The navigation is a separate template, so that the function view and the class-based view share it. The template tag {% querystring %}, which Django provides since version 5.1, builds a link from the current query string and replaces the given parameters, so a link for another page keeps the search term q without any code in the view. The paginator's constant ELLIPSIS is compared with the item to print the dots instead of a link.

library/templates/library/pagination.html
{% if page_obj.has_other_pages %}
<nav class="pagination">
  <p>Books {{ page_obj.start_index }}-{{ page_obj.end_index }} of {{ page_obj.paginator.count }}</p>
  {% if page_obj.has_previous %}
  <a href="{% querystring page=1 %}">First</a>
  <a href="{% querystring page=page_obj.previous_page_number %}">Previous</a>
  {% endif %}
  {% for number in page_range %}
    {% if number == page_obj.number %}
    <strong>{{ number }}</strong>
    {% elif number == page_obj.paginator.ELLIPSIS %}
    <span>{{ number }}</span>
    {% else %}
    <a href="{% querystring page=number %}">{{ number }}</a>
    {% endif %}
  {% endfor %}
  {% if page_obj.has_next %}
  <a href="{% querystring page=page_obj.next_page_number %}">Next</a>
  <a href="{% querystring page=page_obj.paginator.num_pages %}">Last</a>
  {% endif %}
</nav>
{% endif %}

The block is printed only if has_other_pages is true, so a single page has no navigation. The demo script requests several pages and prints the titles and the navigation. Wrong page numbers are tolerated in this view, and the search keeps its term in the links.

demos/web.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.test import Client
from django.test.utils import setup_test_environment

setup_test_environment()

import re

client = Client(raise_request_exception=False)


def nav(response):
    html = response.content.decode()
    match = re.search(r'<nav class="pagination">(.*?)</nav>', html, re.S)
    if not match:
        return '(no navigation)'
    text = re.sub(r'\s+', ' ', match.group(1)).strip()
    return text.replace('> <', '><')


def titles(response):
    items = re.findall(r'<li>(Book \d+)', response.content.decode())
    return f'{items[0]} ... {items[-1]} ({len(items)})' if items else '(none)'


for url in ('/books/', '/books/?page=3', '/books/?page=6', '/books/?page=abc', '/books/?page=99'):
    response = client.get(url)
    print(f'GET {url} -> {response.status_code}, {titles(response)}')
print()
print(nav(client.get('/books/?page=3&q=Book')))
print()
response = client.get('/books/?q=Book 01')
print('GET /books/?q=Book 01:', titles(response))
print(nav(response))
print('GET /books/?q=nothing:', titles(client.get('/books/?q=nothing')))

Note the result of ?page=abc, the first page, and of ?page=99, the last. The links of the navigation contain the search term, and First and Last are only shown where they make sense. A search that finds at most ten books produces no navigation, and a search that finds nothing prints the empty message.

$ uv run python -m demos.web
GET /books/ -> 200, Book 001 ... Book 010 (10)
GET /books/?page=3 -> 200, Book 021 ... Book 030 (10)
GET /books/?page=6 -> 200, Book 051 ... Book 053 (3)
GET /books/?page=abc -> 200, Book 001 ... Book 010 (10)
GET /books/?page=99 -> 200, Book 051 ... Book 053 (3)

<p>Books 21-30 of 53</p><a href="?page=1&amp;q=Book">First</a><a href="?page=2&amp;q=Book">Previous</a><a href="?page=1&amp;q=Book">1</a><a href="?page=2&amp;q=Book">2</a><strong>3</strong><a href="?page=4&amp;q=Book">4</a><a href="?page=5&amp;q=Book">5</a><a href="?page=6&amp;q=Book">6</a><a href="?page=4&amp;q=Book">Next</a><a href="?page=6&amp;q=Book">Last</a>

GET /books/?q=Book 01: Book 010 ... Book 019 (10)
(no navigation)
GET /books/?q=nothing: (none)

Pagination in ListView

The generic ListView paginates when we set paginate_by; the view then creates the paginator and puts paginator, page_obj, is_paginated, and object_list into the context. The page number is read from the URL keyword page or the query parameter page (the name can be changed with page_kwarg). The attribute paginate_orphans corresponds to the argument orphans of the paginator. The view in library/views.py overrides get_queryset() for the search and get_context_data() to add the elided range, so the same template works for both views.

The class-based view is stricter than get_page(). The value last is accepted as the last page, but a page that does not exist, or a value that is not a number, produces a 404 response. This is the more correct behaviour for a public site, as a crawler does not see an endless number of duplicate pages. The function view can do the same by calling paginator.page() and turning EmptyPage into Http404.

demos/cbv.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.test import Client
from django.test.utils import setup_test_environment

setup_test_environment()

import logging
import re

logging.getLogger('django.request').setLevel(logging.ERROR)

client = Client(raise_request_exception=False)


def titles(response):
    items = re.findall(r'<li>(Book \d+)', response.content.decode())
    return f'{items[0]} ... {items[-1]} ({len(items)})' if items else '(none)'


for url in ('/books/cbv/', '/books/cbv/?page=5', '/books/cbv/?page=last', '/books/cbv/?page=6', '/books/cbv/?page=abc'):
    response = client.get(url)
    print(f'GET {url} -> {response.status_code}', titles(response) if response.status_code == 200 else '')

With three orphans the page 5 is the last and holds thirteen books; the page 6 does not exist. The special page last is the same as page 5.

$ uv run python -m demos.cbv
GET /books/cbv/ -> 200 Book 001 ... Book 010 (10)
GET /books/cbv/?page=5 -> 200 Book 041 ... Book 053 (13)
GET /books/cbv/?page=last -> 200 Book 041 ... Book 053 (13)
GET /books/cbv/?page=6 -> 404 
GET /books/cbv/?page=abc -> 404 

Keyset pagination

The page query of Paginator uses OFFSET. To return page 15,001 the database has to find and discard 300,000 rows first, so the time grows with the page number. The COUNT(*) that the paginator needs on every request also grows with the table. Moreover, when rows are inserted or deleted while the user is browsing, the rows shift and a book may be shown twice or skipped. Keyset pagination, also called the seek method or cursor pagination, avoids this: the client sends the last key it has seen, and the query asks for the rows behind it with a condition on an indexed column, such as id > 50. The database uses the index to jump to the place directly, whatever the position is. The price is that there are no page numbers and no total count; the user can only go forward (or backward with the reverse condition). That is exactly what an infinite scroll, an API feed, or a "load more" button needs.

The view book_feed in the listing above returns JSON. It orders by the primary key, filters with id__gt when the parameter after is given, and asks for one row more than a page. That extra row tells whether there is a next page without a count. The response contains the link for the next request. If the order was based on a column that is not unique, such as the year, the condition has to compare the pair (year, id) so that no row is lost; the key must identify a row and have an index. The first script follows the links of the feed until the end.

demos/feed.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.test import Client
from django.test.utils import setup_test_environment

setup_test_environment()

from library.models import Book

client = Client()
url = '/books/feed/'
for step in range(1, 7):
    data = client.get(url).json()
    ids = [book['id'] for book in data['results']]
    print(f'GET {url} -> ids {ids[0]}..{ids[-1]} ({len(ids)}), next = {data["next"]}')
    if not data['next']:
        break
    url = '/books/feed/' + data['next']

The ten-row steps end with a page of three books, and the last response has no next link.

$ uv run python -m demos.feed
GET /books/feed/ -> ids 1..10 (10), next = ?after=10
GET /books/feed/?after=10 -> ids 11..20 (10), next = ?after=20
GET /books/feed/?after=20 -> ids 21..30 (10), next = ?after=30
GET /books/feed/?after=30 -> ids 31..40 (10), next = ?after=40
GET /books/feed/?after=40 -> ids 41..50 (10), next = ?after=50
GET /books/feed/?after=50 -> ids 51..53 (3), next = None

The second script compares the two methods on a table with 300,000 rows. It inserts the rows inside a transaction and rolls the transaction back at the end, so the seeded table is not changed. It prints the tails of the two SQL statements and the time of each query. The numbers depend on the machine; the proportions are what matters.

demos/offset.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

import time

from django.db import connection, transaction
from django.test.utils import CaptureQueriesContext

from library.models import Book

rows = 300_000
size = 20
deep = 15_000


def timed(label, function):
    start = time.perf_counter()
    result = function()
    print(f'{label:<28} {(time.perf_counter() - start) * 1000:7.2f} ms')
    return result


with transaction.atomic():
    Book.objects.all().delete()
    Book.objects.bulk_create(
        (Book(title=f'Book {number:06d}', author='x', year=2000) for number in range(rows)),
        batch_size=5000,
    )
    print('rows:', Book.objects.count())

    with CaptureQueriesContext(connection) as queries:
        list(Book.objects.order_by('id')[deep * size:deep * size + size])
    print(queries.captured_queries[-1]['sql'].split('ORDER BY')[1].strip())
    last_id = Book.objects.order_by('id')[deep * size - 1].id
    with CaptureQueriesContext(connection) as queries:
        list(Book.objects.filter(id__gt=last_id).order_by('id')[:size])
    print(queries.captured_queries[-1]['sql'].split('WHERE')[1].strip())
    print()

    timed('OFFSET, page 1', lambda: list(Book.objects.order_by('id')[:size]))
    timed(f'OFFSET, page {deep + 1}', lambda: list(Book.objects.order_by('id')[deep * size:deep * size + size]))
    timed('count() on every page', lambda: Book.objects.count())
    timed(f'keyset, after id {last_id}', lambda: list(Book.objects.filter(id__gt=last_id).order_by('id')[:size]))
    transaction.set_rollback(True)
print('rows after rollback:', Book.objects.count())

The offset query for a late page is several times slower than the same page found by the keyset, and it would be slower still on a larger table or with a more complex query. The first page is fast in both cases. The COUNT(*) is a separate cost that a keyset feed does not pay. For most sites with a few dozen pages Paginator is perfectly adequate, and the page numbers are worth more than the speed.

$ uv run python -m demos.offset
rows: 300000
"library_book"."id" ASC LIMIT 20 OFFSET 300000
"library_book"."id" > 300053 ORDER BY "library_book"."id" ASC LIMIT 20

OFFSET, page 1                  0.27 ms
OFFSET, page 15001              2.99 ms
count() on every page           1.12 ms
keyset, after id 300053         0.31 ms
rows after rollback: 53

Testing pagination

The tests create the 53 books in setUpTestData and check the paginator, the context and the links of the function view, the behaviour of the class-based view for the last and the missing pages, and the two steps of the feed. The context of a response gives access to page_obj without parsing the HTML.

library/tests.py
from django.core.paginator import Paginator
from django.test import TestCase
from django.urls import reverse

from .models import Book


class PaginationTests(TestCase):

    @classmethod
    def setUpTestData(cls):
        Book.objects.bulk_create(
            Book(title=f'Book {n:03d}', author='x', year=2000) for n in range(1, 54))

    def test_paginator(self):
        paginator = Paginator(Book.objects.order_by('title'), 10)
        self.assertEqual(paginator.num_pages, 6)
        self.assertEqual(len(paginator.page(6)), 3)

    def test_first_page(self):
        response = self.client.get(reverse('list'))
        self.assertEqual(response.context['page_obj'].number, 1)
        self.assertContains(response, 'Books 1-10 of 53')

    def test_bad_page_falls_back(self):
        self.assertEqual(self.client.get(reverse('list'), {'page': 'abc'}).context['page_obj'].number, 1)
        self.assertEqual(self.client.get(reverse('list'), {'page': 99}).context['page_obj'].number, 6)

    def test_links_keep_the_search(self):
        response = self.client.get(reverse('list'), {'q': 'Book 0'})
        self.assertContains(response, '?q=Book+0&amp;page=2')

    def test_class_based_view_orphans(self):
        response = self.client.get(reverse('list-cbv'), {'page': 5})
        self.assertEqual(len(response.context['page_obj']), 13)
        self.assertEqual(self.client.get(reverse('list-cbv'), {'page': 6}).status_code, 404)
        self.assertEqual(self.client.get(reverse('list-cbv'), {'page': 'last'}).status_code, 200)

    def test_feed(self):
        first = self.client.get(reverse('feed')).json()
        self.assertEqual(len(first['results']), 10)
        second = self.client.get(reverse('feed') + first['next']).json()
        self.assertGreater(second['results'][0]['id'], first['results'][-1]['id'])

The six tests pass. The same table, however large, would make the tests slow only when we paginate it in the test itself, so a few dozen rows are enough to test the logic.

$ uv run python manage.py test
Found 6 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
......
----------------------------------------------------------------------
Ran 6 tests in 0.016s

OK
Destroying test database for alias 'default'...

Source

Django documentation: Pagination

Django documentation: Paginator reference

Django documentation: querystring template tag

In this article we have paginated a QuerySet with Paginator, handled wrong page numbers, built the navigation with an elided range and the querystring tag, used paginate_by in ListView, implemented keyset pagination for large tables, and tested it. Related articles are Django QuerySets, Django class-based views, and Django query performance.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.