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 |
|
|
Page a list view with one line |
|
|
Keep |
|
|
Show "1 2 … 8 9 10 … 99 100" |
|
|
Page from an |
|
|
Page through millions of rows or a live feed |
Keyset pagination (or DRF |
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.
>>> 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
Source: Django: Pagination - Example
The attributes and methods you will use most:
| Member | Meaning |
|---|---|
|
Total number of objects, number of pages, and a 1-based |
|
Returns the |
|
Forgiving version: a non-number gives the first page, a negative or too-large number gives the last page. |
|
Page numbers with |
|
Booleans for building the navigation. |
|
The neighbouring numbers; they raise |
|
1-based position of the first and last item ("showing 21-30 of 45"). |
|
The items, the page number and the owning paginator. |
Source: Django: 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).
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()
Source: Django: Paginator.get_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.
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.
Source: Django: Paginator.object_list
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:
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
Source: Django: Paginator.error_messages
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.
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 |
|---|---|
|
Items per page; |
|
Extra items the last page may absorb (the |
|
Name of the parameter, |
|
The paginator to use; a class with another constructor also needs |
|
Returns the 4-tuple |
|
|
Source: Django: MultipleObjectMixin
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.
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))
Source: Django: Paginating a ListView
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.
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.
{% 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 %}
Source: Django: querystring template tag
Requesting /books/?q=Book&page=2&per_page=5 rendered links that kept both extra parameters and only changed the page:
<a href="?q=Book&page=1&per_page=5">previous</a>
<strong aria-current="page">2</strong>
<a href="?q=Book&page=3&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.
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,
}
)
Source: Django: AsyncPaginator class
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.
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},
)
Source: Django: QuerySet API reference
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 %}
Source: Django: querystring template tag
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.
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(). ThePageNotAnInteger/EmptyPagedance that returned the first or last page by hand is replaced byPaginator.get_page(), added in Django 2.0. Keeppage()for the strict case only. -
Printing every page number. A
forloop overpaginator.page_rangedoes 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 copiesrequest.GET, are replaced by{% querystring %}(Django 5.1). -
Blocking pagination inside async code. Django 6.0 added
AsyncPaginatorandAsyncPage; the older advice to wrap the paginator in a thread helper is only needed on 5.2 LTS. -
Unchecked
per_pageand orphans. Letting the client pick any page size, ororphans >= per_page, is discouraged; the latter has been deprecated since 6.0.
What changed recently
| Version | Change |
|---|---|
2.0 |
|
3.2 |
|
5.0 |
|
5.1 |
|
6.0 |
|
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 withlist(). -
Calling
next_page_number()withouthas_next(): it raisesInvalidPage. -
Two lists on one page sharing the
pageparameter: set a differentpage_kwargon one. -
Forgetting that
ListViewanswers404whereget_page()would have returned a page. -
Using offsets for a feed that is written to while it is read: use keyset pagination.