Pagination

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, checked on 2026-10-03; where Django 5.2 LTS (5.2.17) behaves differently the text says so. Every listing was run in a scratch project on Python 3.13.16 and Django 6.1.1 with SQLite: manage.py check, a suite of 13 tests (manage.py test) and a curl session against runserver all passed, and the output shown is the real output. Nothing on this page needs PostgreSQL or Redis to run; the one place where PostgreSQL changes the advice is called out in the text.

A catalogue with thousands of books cannot be rendered on one page: the query is slow, the HTML is huge and nobody reads past the first screen. Pagination splits the result into numbered pages with "previous/next" links. Django covers it in three layers: the Paginator and Page classes in django.core.paginator do the arithmetic, ListView wires them into a class-based view with a single attribute, and the {% querystring %} template tag builds the links without losing the search filters. This page covers all three, the async variants added in Django 6.0, the performance trap hidden in LIMIT/OFFSET, and the keyset alternative for tables too large for offsets. The views are described on Class-Based Views and Generic Views, the queries on Making Queries and the model in the examples on Models & Fields.

In a hurry: what to use for what

I need to…​ Use Section

Page a list or queryset by hand

Paginator(object_list, per_page).get_page(request.GET.get("page"))

Paginator and Page, Pagination in a function-based view

Page a list view with one line

paginate_by = 10 on a ListView

ListView and paginate_by

Keep ?q=…​ filters when moving between pages

{% querystring page=page_obj.next_page_number %}

Links that keep the filters: the querystring tag

Show "1 2 …​ 8 9 10 …​ 99 100"

paginator.get_elided_page_range(page_number)

Compact page lists with get_elided_page_range()

Page from an async view

AsyncPaginator and AsyncPage (Django 6.0+)

AsyncPaginator and AsyncPage (Django 6.0)

Page through millions of rows or a live feed

Keyset pagination (or DRF CursorPagination)

Keyset pagination for large tables

Paginator and Page

Paginator(object_list, per_page, orphans=0, allow_empty_first_page=True, error_messages=None) takes any sliceable object that has a count() or __len__() method: a list, a tuple, a QuerySet. It first calls count() and only falls back to len(), which is why a QuerySet is counted with one cheap SELECT COUNT(*) instead of being loaded into memory. A paginator behaves like a sequence of Page objects, and a Page behaves like a sequence of the items on it.

Paginator on a plain list
>>> from django.core.paginator import Paginator
>>> p = Paginator(["john", "paul", "george", "ringo"], 2)
>>> p.count, p.num_pages, p.page_range
(4, 2, range(1, 3))
>>> page2 = p.page(2)
>>> page2.object_list
['george', 'ringo']
>>> page2.has_next(), page2.has_previous(), page2.has_other_pages()
(False, True, True)
>>> page2.start_index(), page2.end_index()   # 1-based, relative to all items
(3, 4)
>>> p.page(3)
Traceback (most recent call last):
...
EmptyPage: That page contains no results

The attributes and methods you will use most:

Member Meaning

Paginator.count, num_pages, page_range

Total number of objects, number of pages, and a 1-based range of page numbers.

Paginator.page(number)

Returns the Page; raises PageNotAnInteger if the value is not an integer and EmptyPage if the page does not exist. Both inherit from InvalidPage.

Paginator.get_page(number)

Forgiving version: a non-number gives the first page, a negative or too-large number gives the last page.

Paginator.get_elided_page_range(number, *, on_each_side=3, on_ends=2)

Page numbers with Paginator.ELLIPSIS substituted for the skipped stretches.

Page.has_next(), has_previous(), has_other_pages()

Booleans for building the navigation.

Page.next_page_number(), previous_page_number()

The neighbouring numbers; they raise InvalidPage when there is no such page, so test with has_next() first.

Page.start_index(), end_index()

1-based position of the first and last item ("showing 21-30 of 45").

Page.object_list, number, paginator

The items, the page number and the owning paginator.

get_page() versus page()

Page numbers come from the URL, so they are user input and can be anything: ?page=abc, ?page=0, ?page=9999. page() is strict and raises; get_page() never raises on a bad number. It returns page 1 for a value that is not a number and the last page for a negative or too-large one (it raises EmptyPage only when you pass allow_empty_first_page=False and the list is empty).

The strict and the forgiving lookup, with an ordered queryset
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator

from catalog.models import Book

paginator = Paginator(Book.objects.all(), 10)   # Book.Meta.ordering = ["-published", "-pk"]

paginator.get_page("abc").number    # 1   (not a number)
paginator.get_page(None).number     # 1   (parameter missing)
paginator.get_page(-4).number       # 3   (the last page of a 25-item list)
paginator.get_page(99).number       # 3

try:
    paginator.page("abc")
except PageNotAnInteger:
    ...                             # raised only by page()

Use get_page() for end-user pages. Use page() when a wrong number should be an error, for example in an API that answers 404 instead of silently serving a different page.

Always order what you paginate

A page is a slice, LIMIT 10 OFFSET 20, and without ORDER BY the database may return rows in any order, so a row can appear on two pages or on none. Django warns about it: paginating an unordered queryset emits UnorderedObjectListWarning. Order the queryset with order_by() or give the model a default Meta.ordering, and make the order total by adding a unique tiebreaker such as the primary key: two books published on the same day need -pk to keep a stable sequence.

The scratch project showed exactly what a page costs: one count and one slice.

The two queries behind Paginator(Book.objects.all(), 10).get_page(3)
SELECT COUNT(*) AS "__count" FROM "catalog_book"
SELECT "catalog_book"."id", "catalog_book"."title", "catalog_book"."published" FROM "catalog_book"
  ORDER BY "catalog_book"."published" DESC, "catalog_book"."id" DESC LIMIT 10 OFFSET 20

Without an ordering, the same call printed UnorderedObjectListWarning: Pagination may yield inconsistent results with an unordered object_list: <class 'catalog.models.Book'> QuerySet.

Orphans, empty lists and custom error messages

orphans stops the last page from holding one or two lonely items: if the last page would have orphans items or fewer, they are appended to the previous page, which becomes the last. With 23 items, per_page=10 and orphans=3 there are two pages of 10 and 13 items. Keep orphans smaller than per_page: since Django 6.0 passing an orphans value greater than or equal to per_page is deprecated.

allow_empty_first_page=False makes an empty list raise EmptyPage instead of producing an empty page one. Since Django 5.0 error_messages overrides the three default texts, using the keys invalid_page, min_page and no_results:

orphans and error_messages
p = Paginator(range(23), 10, orphans=3)
p.num_pages, len(p.page(2))        # (2, 13)

p = Paginator([1, 2, 3], 2, error_messages={"no_results": "Page does not exist"})
p.page(5)                          # EmptyPage: Page does not exist

Pagination in a function-based view

The view reads the page parameter, asks the paginator for the page and passes it to the template. There is no try/except, because get_page() already absorbs every invalid value.

catalog/views.py
from django.core.paginator import Paginator
from django.shortcuts import render

from .models import Book


def book_list(request):
    paginator = Paginator(Book.objects.all(), 10)
    page_obj = paginator.get_page(request.GET.get("page"))
    return render(request, "catalog/book_list_fbv.html", {"page_obj": page_obj})

In the test, /books-fbv/?page=2 rendered page 2, ?page=zzz rendered page 1 and ?page=99 rendered the last page, 5. The template is the pager from Links that keep the filters: the querystring tag; a Page iterates directly, so the loop is {% for book in page_obj %}.

The paginator sits in the request/response cycle exactly where the queryset is evaluated: the view builds a lazy queryset, the paginator adds the count query and the slice, and the template iteration runs the slice. Nothing is fetched before the template asks for the items, so a view that paginates never loads the whole table. See The Request/Response Cycle for the full path.

ListView and paginate_by

A ListView paginates with one attribute. paginate_by sets the page size, and the view adds paginator, page_obj and is_paginated to the context. The page number comes from the page query parameter or from a page captured in the URLconf. The special value last means the final page.

Attribute or method Purpose

paginate_by, get_paginate_by(queryset)

Items per page; None disables pagination. Override the method to let the user choose a size.

paginate_orphans, get_paginate_orphans()

Extra items the last page may absorb (the orphans argument).

page_kwarg

Name of the parameter, "page" by default; change it when two lists share a URL.

paginator_class, get_paginator()

The paginator to use; a class with another constructor also needs get_paginator().

paginate_queryset(queryset, page_size)

Returns the 4-tuple (paginator, page, object_list, is_paginated).

allow_empty

False makes an empty list a 404 instead of an empty page.

Unlike get_page(), a ListView is strict: a page that is not a number or not last, or a page that does not exist, answers 404. The view below adds a search filter, orphans and a user-selectable page size that is clamped, because a client-supplied per_page=1000000 would defeat the purpose of paginating.

catalog/views.py
from django.views.generic import ListView

from .models import Book


class BookListView(ListView):
    model = Book
    paginate_by = 10
    paginate_orphans = 5
    template_name = "catalog/book_list.html"

    def get_queryset(self):
        qs = super().get_queryset()
        q = self.request.GET.get("q", "").strip()
        return qs.filter(title__icontains=q) if q else qs

    def get_paginate_by(self, queryset):
        try:
            per_page = int(self.request.GET.get("per_page", self.paginate_by))
        except ValueError:
            return self.paginate_by
        return max(1, min(per_page, 50))

With 45 books, the tests confirmed: four pages (the last holds 15, because 5 orphans are absorbed); ?page=last gives the last page; ?page=99 and ?page=abc return 404; ?per_page=1000 is clamped to 50. The same view is described as a generic view on Generic Views.

Compact page lists with get_elided_page_range()

Listing every number is unusable at 500 pages. get_elided_page_range(number, *, on_each_side=3, on_ends=2) returns the page numbers to show, with Paginator.ELLIPSIS (the translatable string …) replacing the gaps. For page 10 of 50 with the defaults it yields [1, 2, '…', 7, 8, 9, 10, 11, 12, 13, '…', 49, 50]. Build it in the view and pass it as a list: it is a generator, so iterating it twice would find it empty.

Elided range in the context
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        if context["is_paginated"]:
            context["page_range"] = list(
                context["paginator"].get_elided_page_range(
                    context["page_obj"].number, on_each_side=2, on_ends=1
                )
            )
        return context

For page 12 of a 20-page list the test printed [1, '…', 10, 11, 12, 13, 14, '…', 20]. The template compares each entry with page_obj.paginator.ELLIPSIS to render the gaps as plain text.

Links that keep the filters: the querystring tag

A search page needs "next" to go to ?q=django&page=3, not just ?page=3. Hand-built links such as ?page=…​&q=…​ forget a parameter sooner or later. Since Django 5.1 the {% querystring %} tag builds the link from the current request.GET: keyword arguments set or replace parameters, None removes one, a list produces repeated parameters, and the result always starts with ?. It needs django.template.context_processors.request (enabled in a default project) or an explicit mapping argument. Since 6.0 it also accepts several mapping arguments, and consistently returns ? even when the query string is empty.

catalog/templates/catalog/book_list.html
{% if is_paginated %}
<nav aria-label="Pagination">
  {% if page_obj.has_previous %}
    <a href="{% querystring page=page_obj.previous_page_number %}">previous</a>
  {% endif %}
  {% for n in page_range %}
    {% if n == page_obj.paginator.ELLIPSIS %}<span>{{ n }}</span>
    {% elif n == page_obj.number %}<strong aria-current="page">{{ n }}</strong>
    {% else %}<a href="{% querystring page=n %}">{{ n }}</a>{% endif %}
  {% endfor %}
  {% if page_obj.has_next %}
    <a href="{% querystring page=page_obj.next_page_number %}">next</a>
  {% endif %}
</nav>
{% endif %}

Requesting /books/?q=Book&page=2&per_page=5 rendered links that kept both extra parameters and only changed the page:

Rendered output (excerpt)
<a href="?q=Book&amp;page=1&amp;per_page=5">previous</a>
<strong aria-current="page">2</strong>
<a href="?q=Book&amp;page=3&amp;per_page=5">3</a>
<span>…</span>

AsyncPaginator and AsyncPage (Django 6.0)

An async view cannot call the synchronous Paginator, because counting and slicing a queryset touch the database. Django 6.0 added AsyncPaginator and AsyncPage, which have the same constructor and members with an a prefix: aget_page(), apage(), acount(), anum_pages(), apage_range(), ahas_next(), ahas_previous() and so on. A page is not a sequence until you await aget_object_list(), but its object_list queryset can be consumed with async for.

An async view
from django.core.paginator import AsyncPaginator
from django.http import JsonResponse

from .models import Book


async def async_book_list(request):
    paginator = AsyncPaginator(Book.objects.all(), 10)
    page = await paginator.aget_page(request.GET.get("page"))
    titles = [book.title async for book in page.object_list]
    return JsonResponse(
        {
            "number": page.number,
            "pages": await paginator.anum_pages(),
            "has_next": await page.ahas_next(),
            "titles": titles,
        }
    )

curl "http://127.0.0.1:53871/books-async/?page=5" returned {"number": 5, "pages": 5, "has_next": false, "titles": […​]} with the five oldest books. In a test, await paginator.acount() returned 25, anum_pages() returned 3 and await paginator.apage_range() returned range(1, 4). On Django 5.2 LTS, which does not have these classes, wrap the synchronous paginator with sync_to_async() or use the async ORM methods by hand. See Making Queries for the async ORM.

Why deep pages get slow

LIMIT 10 OFFSET 200000 makes the database walk and discard 200,000 rows before returning ten, so the cost of page N grows with N. The same applies to COUNT(*) on a big table, which on PostgreSQL scans many rows on every request. Django’s reference page warns about this for high page numbers. For most sites it does not matter: search engines and humans rarely go past page 20, and an index on the ordering columns keeps shallow pages fast. Measure with EXPLAIN before optimising; see Query Optimization & Fetch Modes and Diagnosing Slow Queries. Cheap mitigations that keep the Paginator:

  • cap the maximum page, or the page size, as shown with get_paginate_by();

  • paginate a queryset narrowed by filters, indexes included, instead of the whole table;

  • do not show the total ("page 3 of 4,000") when you do not need it, and use keyset pagination below.

Keyset pagination for large tables

Keyset (also called seek-method or cursor) pagination does not skip rows. It remembers the sort key of the last row the reader saw and asks for the rows after it: WHERE (published, id) < (last_published, last_id) ORDER BY published DESC, id DESC LIMIT 10. With an index on those columns the cost is the same for page 1 and page 1,000, and rows inserted while the reader pages can neither shift items onto a second page nor hide any. The trade-off is that you lose random access ("go to page 57") and the total count, which is why it fits feeds, infinite scroll and APIs. The concept, the SQL for each database and the trade-offs are explained once on Pagination: Offset vs. Keyset; this section shows the Django side.

Django has no built-in keyset paginator. With a two-column ordering the seek condition is a pair of Q objects, which works on every database (PostgreSQL could also compare row values, but a portable form is shown here). The view fetches one row more than the page size to learn whether there is a next page without a count query.

catalog/views.py
from datetime import date

from django.db.models import Q
from django.shortcuts import render

from .models import Book

PAGE_SIZE = 10


def keyset_list(request):
    qs = Book.objects.all()  # ordered by -published, -pk
    after_date = request.GET.get("after_date")
    after_pk = request.GET.get("after_pk")
    if after_date and after_pk:
        last = date.fromisoformat(after_date)
        qs = qs.filter(Q(published__lt=last) | Q(published=last, pk__lt=int(after_pk)))
    rows = list(qs[: PAGE_SIZE + 1])      # one extra row: is there more?
    has_next = len(rows) > PAGE_SIZE
    rows = rows[:PAGE_SIZE]
    return render(
        request,
        "catalog/book_keyset.html",
        {"books": rows, "has_next": has_next, "last": rows[-1] if rows else None},
    )

The template links to the next page with the same tag, now carrying the key of the last row:

{% if has_next %}
<a href="{% querystring after_date=last.published|date:'Y-m-d' after_pk=last.pk %}">older</a>
{% endif %}

The test walked all 45 books page by page and inserted a new, newer book after the first page. It still received exactly 45 distinct books, with no duplicate and no gap; with offset pagination the same insertion would have shifted every later row by one and repeated a book at the page boundary. Two production notes: add an index matching the ordering, for example models.Index(fields=["-published", "-id"]) (see Constraints, Indexes & Composite Keys), and treat the key in the URL as user input: it is validated here by date.fromisoformat() and int(), and a malformed value would need a 400 or a fallback to the first page. Sorting by a column that is not unique without a tiebreaker silently loses rows.

In APIs: DRF and Django Ninja

You rarely write this by hand for an API. Django REST framework ships PageNumberPagination, LimitOffsetPagination and CursorPagination, enabled with DEFAULT_PAGINATION_CLASS and PAGE_SIZE or per view with pagination_class. Its cursor style is keyset pagination with an opaque cursor, a unique unchanging ordering and no random access. Django Ninja provides an @paginate decorator with limit/offset as the default and page-number and cursor classes as alternatives.

A DRF cursor-paginated list
from rest_framework import generics, serializers
from rest_framework.pagination import CursorPagination

from .models import Book


class BookSerializer(serializers.ModelSerializer):
    class Meta:
        model = Book
        fields = ["id", "title", "published"]


class BookCursorPagination(CursorPagination):
    page_size = 10
    ordering = ("-published", "-id")


class BookList(generics.ListAPIView):
    queryset = Book.objects.all()
    serializer_class = BookSerializer
    pagination_class = BookCursorPagination

GET /api/books/ returned ten results and a next link of the form /api/books/?cursor=cD0yMDI2…; following it returned the next ten, with a previous link. The cursor is an encoded position, not something clients should build themselves. See Django Ninja: Pagination for the Ninja equivalent.

Where the books are outdated

Older Django tutorials and books show patterns that still run but are no longer the best way:

  • try/except around page(). The PageNotAnInteger / EmptyPage dance that returned the first or last page by hand is replaced by Paginator.get_page(), added in Django 2.0. Keep page() for the strict case only.

  • Printing every page number. A for loop over paginator.page_range does not scale; get_elided_page_range() has been available since Django 3.2.

  • Hand-assembled query strings. Links such as ?page=2&q=…​ assembled in the template, or a custom template tag that copies request.GET, are replaced by {% querystring %} (Django 5.1).

  • Blocking pagination inside async code. Django 6.0 added AsyncPaginator and AsyncPage; the older advice to wrap the paginator in a thread helper is only needed on 5.2 LTS.

  • Unchecked per_page and orphans. Letting the client pick any page size, or orphans >= per_page, is discouraged; the latter has been deprecated since 6.0.

What changed recently

Version Change

2.0

Paginator.get_page() added.

3.2

Paginator.get_elided_page_range() and Paginator.ELLIPSIS added; the admin’s pagination links became 1-indexed.

5.0

Paginator.error_messages argument added.

5.1

{% querystring %} template tag added.

6.0

AsyncPaginator and AsyncPage added; {% querystring %} always returns a leading ? and accepts several mappings; orphans >= per_page deprecated.

6.1

No pagination changes in the release notes; the 6.1 documentation describes the same API.

Pitfalls

  • Paginating an unordered queryset: rows move between pages. Order by a total key.

  • Calling Paginator(…​) on a list you built from the whole table (list(Book.objects.all())): it loads everything. Pass the queryset.

  • Iterating get_elided_page_range() twice: it is a generator; convert it with list().

  • Calling next_page_number() without has_next(): it raises InvalidPage.

  • Two lists on one page sharing the page parameter: set a different page_kwarg on one.

  • Forgetting that ListView answers 404 where get_page() would have returned a page.

  • Using offsets for a feed that is written to while it is read: use keyset pagination.