Pagination¶
Efficient page-based slicing for QuerySet results using COUNT + LIMIT/OFFSET. HyperDjango's paginator caches the count query, supports orphan merging, and integrates directly with ListView for template-driven pagination.
Quick Start¶
from hyperdjango.paginator import Paginator
paginator = Paginator(User.objects.filter(is_active=True), per_page=25)
page = await paginator.page(1)
for user in page:
print(user.name)
print(f"Page {page.number} of {page.num_pages}")
print(f"Showing {page.start_index}-{page.end_index} of {page.count}")
Paginator Class¶
Paginator(
queryset, # QuerySet to paginate
per_page=25, # Items per page (minimum 1)
orphans=0, # Min items on last page before merging
allow_empty_first_page=True, # Allow page(1) on empty queryset
)
Parameters¶
queryset¶
Any QuerySet instance. The paginator calls .count() once (cached) and then .offset().limit().all() for each page request:
# Filter first, then paginate
qs = Article.objects.filter(published=True).order_by("-created_at")
paginator = Paginator(qs, per_page=10)
per_page¶
Number of items per page. Minimum enforced to 1:
orphans¶
If the last page would have fewer items than orphans, those items are merged with the previous page. This prevents tiny final pages:
# 53 items, per_page=10, orphans=0
# Result: 6 pages (last page has 3 items)
paginator = Paginator(queryset, per_page=10)
# 53 items, per_page=10, orphans=3
# Result: 5 pages (last page has 13 items)
paginator = Paginator(queryset, per_page=10, orphans=3)
# 53 items, per_page=10, orphans=5
# Result: 5 pages (last page has 13 items — 3 < 5, so merged)
paginator = Paginator(queryset, per_page=10, orphans=5)
The orphan calculation: num_pages = ceil((count - orphans) / per_page). On the last page, all remaining items are returned (which may exceed per_page).
allow_empty_first_page¶
When True (default), requesting page 1 on an empty queryset returns an empty page instead of raising EmptyPage. When False, any request on an empty queryset raises EmptyPage:
# Empty queryset, allow_empty_first_page=True (default)
paginator = Paginator(empty_qs, per_page=25)
page = await paginator.page(1) # Returns empty page
print(len(page)) # 0
# Empty queryset, allow_empty_first_page=False
paginator = Paginator(empty_qs, per_page=25, allow_empty_first_page=False)
page = await paginator.page(1) # Raises EmptyPage
Paginator Methods¶
await paginator.page(number)¶
Return a Page object for the given 1-based page number. This is the primary method -- it runs the count query (cached after first call) and then fetches the slice:
Raises PageNotAnInteger if the number cannot be converted to an integer. Raises EmptyPage if the page number is valid but contains no results (beyond the last page, or page < 1).
await paginator.get_count()¶
Return the total item count. The count is cached after the first call -- subsequent calls return the cached value without hitting the database:
total = await paginator.get_count()
print(f"Total items: {total}")
# Second call returns cached value (no SQL)
total_again = await paginator.get_count()
await paginator.num_pages¶
Async property returning the total number of pages:
await paginator.page_range¶
Async property returning a range of valid page numbers:
Page Object¶
A Page is a dataclass representing a single page of results. It supports len(), iter(), [] indexing, and bool().
Properties¶
| Property | Type | Description |
|---|---|---|
items |
list |
Model instances on this page |
number |
int |
Current page number (1-based) |
num_pages |
int |
Total number of pages |
count |
int |
Total items across all pages |
per_page |
int |
Items per page setting |
has_next |
bool |
True if there is a next page |
has_previous |
bool |
True if there is a previous page |
next_page_number |
int |
Next page number (raises InvalidPage if last) |
previous_page_number |
int |
Previous page number (raises InvalidPage if first) |
start_index |
int |
1-based index of first item on this page |
end_index |
int |
1-based index of last item on this page |
page_range |
range |
Range of all valid page numbers |
Protocol Support¶
page = await paginator.page(2)
# Length
len(page) # Number of items on this page
# Iteration
for item in page:
print(item.name)
# Indexing
first = page[0]
last = page[-1]
slice = page[2:5]
# Boolean (True if page has items)
if page:
print("Has items")
Navigation Properties¶
page = await paginator.page(3)
# Check boundaries
page.has_next # True if page 4 exists
page.has_previous # True (page 2 exists)
# Get adjacent page numbers
page.next_page_number # 4
page.previous_page_number # 2
# These raise InvalidPage at boundaries
first_page = await paginator.page(1)
first_page.previous_page_number # Raises InvalidPage
last_page = await paginator.page(page.num_pages)
last_page.next_page_number # Raises InvalidPage
Index Properties¶
start_index and end_index are 1-based, suitable for display ("Showing 26-50 of 100"):
page = await paginator.page(2) # per_page=25, 100 total items
page.start_index # 26
page.end_index # 50
# Empty page returns 0 for both
empty = await Paginator(empty_qs, per_page=25).page(1)
empty.start_index # 0
empty.end_index # 0
Async Iteration¶
Iterate over all pages sequentially:
paginator = Paginator(Article.objects.all(), per_page=100)
async for page in paginator:
for article in page:
await process(article)
print(f"Processed page {page.number} of {page.num_pages}")
This is useful for batch processing all records. Each iteration fetches one page from the database.
Error Handling¶
Three exception types, all inheriting from InvalidPage:
from hyperdjango.paginator import InvalidPage, PageNotAnInteger, EmptyPage
# PageNotAnInteger — non-integer page number
try:
page = await paginator.page("abc")
except PageNotAnInteger:
page = await paginator.page(1)
# EmptyPage — valid integer but no items on that page
try:
page = await paginator.page(999)
except EmptyPage:
page = await paginator.page(1)
# EmptyPage — page number less than 1
try:
page = await paginator.page(0)
except EmptyPage:
page = await paginator.page(1)
# InvalidPage — catch all pagination errors
try:
page = await paginator.page(user_input)
except InvalidPage:
page = await paginator.page(1)
Safe Page Access Pattern¶
A common pattern is to default to page 1 on any error:
async def get_safe_page(paginator, page_number):
"""Get a page, falling back to page 1 on any error."""
try:
return await paginator.page(page_number)
except InvalidPage:
return await paginator.page(1)
ListView Pagination¶
ListView integrates with Paginator automatically. Set per_page to enable:
from hyperdjango.views import ListView
class ArticleList(ListView):
model = Article
per_page = 25 # Items per page (0 = no pagination)
ordering = "-created_at" # Default ordering
template_name = "articles/list.html"
context_object_name = "articles"
The page number is read from the ?page= query parameter. When pagination is active, the template context includes:
| Context Variable | Type | Description |
|---|---|---|
page |
Page |
The current Page object |
paginator |
Paginator |
The Paginator instance |
object_list / custom name |
list |
Items on this page (same as page.items) |
How ListView Pagination Works¶
# Internally, ListView.get() does approximately:
qs = self.get_queryset()
paginator = Paginator(qs, per_page=self.per_page)
page_num = request.GET.get("page", "1")
try:
page = await paginator.page(page_num)
except Exception:
page = await paginator.page(1)
items = page.items
context = self.get_context_data(page=page, paginator=paginator)
Disabling Pagination¶
Set per_page = 0 to disable pagination and return all results:
Template Patterns¶
Basic Page Navigation¶
{# articles/list.html #} {% for article in articles %}
<div class="article">
<h2>{{ article.title }}</h2>
<p>{{ article.summary }}</p>
</div>
{% endfor %} {# Pagination controls #} {% if page.num_pages > 1 %}
<nav class="pagination">
{% if page.has_previous %}
<a href="?page=1">First</a>
<a href="?page={{ page.previous_page_number }}">Previous</a>
{% endif %}
<span>Page {{ page.number }} of {{ page.num_pages }}</span>
{% if page.has_next %}
<a href="?page={{ page.next_page_number }}">Next</a>
<a href="?page={{ page.num_pages }}">Last</a>
{% endif %}
</nav>
{% endif %}
Showing Item Count¶
{% if page.count > 0 %}
<p>
Showing {{ page.start_index }}-{{ page.end_index }} of {{ page.count }}
results
</p>
{% else %}
<p>No results found.</p>
{% endif %}
Page Number List¶
<nav class="pagination">
{% for p in page.page_range %} {% if p == page.number %}
<span class="current">{{ p }}</span>
{% else %}
<a href="?page={{ p }}">{{ p }}</a>
{% endif %} {% endfor %}
</nav>
Preserving Other Query Parameters¶
When the page has search or filter parameters, preserve them across pagination links:
{# If URL is /articles/?q=django&category=tutorial&page=2 #} {% if page.has_next
%}
<a
href="?q={{ request.GET.q }}&category={{ request.GET.category }}&page={{ page.next_page_number }}"
>
Next
</a>
{% endif %}
URL Query Parameter Patterns¶
Reading the Page Parameter¶
In a standalone route (outside ListView), read ?page= from the request:
from hyperdjango import HyperApp, Request, Response
from hyperdjango.paginator import Paginator
app = HyperApp()
@app.route("/articles")
async def article_list(request: Request) -> Response:
page_num = request.GET.get("page", "1")
qs = Article.objects.filter(published=True).order_by("-created_at")
paginator = Paginator(qs, per_page=20)
try:
page = await paginator.page(page_num)
except InvalidPage:
page = await paginator.page(1)
return Response.json({
"items": [{"id": a.id, "title": a.title} for a in page],
"page": page.number,
"num_pages": page.num_pages,
"count": page.count,
"has_next": page.has_next,
"has_previous": page.has_previous,
})
API Pagination Response Format¶
A standard JSON pagination envelope:
@app.route("/api/users")
async def user_list(request: Request) -> Response:
page_num = request.GET.get("page", "1")
per_page = min(int(request.GET.get("per_page", "25")), 100) # Cap at 100
paginator = Paginator(User.objects.all(), per_page=per_page)
try:
page = await paginator.page(page_num)
except InvalidPage:
page = await paginator.page(1)
return Response.json({
"results": [user.to_dict() for user in page],
"pagination": {
"page": page.number,
"per_page": per_page,
"total_pages": page.num_pages,
"total_count": page.count,
"has_next": page.has_next,
"has_previous": page.has_previous,
},
})
Count Optimization¶
The Paginator caches the COUNT query result after the first call. This means:
- The first call to
page()runsSELECT COUNT(*)+SELECT ... LIMIT/OFFSET - Subsequent calls to
page()only run theSELECT ... LIMIT/OFFSET - The count is cached for the lifetime of the Paginator instance
paginator = Paginator(User.objects.all(), per_page=25)
# First page: 2 queries (COUNT + SELECT)
page1 = await paginator.page(1)
# Second page: 1 query (SELECT only — count is cached)
page2 = await paginator.page(2)
# Explicit count access (returns cached value)
total = await paginator.get_count()
If the underlying data changes between page requests, the cached count may be stale. For most use cases this is acceptable. For strict accuracy, create a new Paginator instance for each request.
When COUNT is Expensive¶
For very large tables, COUNT(*) can be slow. Strategies:
Use the query cache to avoid repeated count queries:
# Cache the queryset results (including count)
qs = Article.objects.cache(ttl=60).filter(published=True)
paginator = Paginator(qs, per_page=25)
Pre-compute counts for known queries:
# Store counts in a stats table, update periodically
count = await ArticleStats.objects.get(name="published_count")
Cursor-Based Pagination Pattern¶
For large datasets where OFFSET becomes expensive, use cursor-based pagination with a known ordering column:
@app.route("/api/articles")
async def articles_cursor(request: Request) -> Response:
cursor = request.GET.get("cursor") # Last seen ID
limit = min(int(request.GET.get("limit", "25")), 100)
qs = Article.objects.filter(published=True).order_by("-id")
if cursor:
# Fetch items after the cursor
qs = qs.filter(id__lt=int(cursor))
items = await qs.limit(limit + 1).all() # Fetch one extra to detect next page
has_next = len(items) > limit
if has_next:
items = items[:limit]
next_cursor = str(items[-1].id) if items and has_next else None
return Response.json({
"results": [a.to_dict() for a in items],
"next_cursor": next_cursor,
"has_next": has_next,
})
Cursor-based pagination advantages:
- O(1) performance regardless of page depth (no OFFSET scan)
- Stable results even when data is inserted/deleted between pages
- Works well with real-time feeds and infinite scroll UIs
Cursor-based pagination limitations:
- Cannot jump to arbitrary page numbers
- Requires a unique, ordered column (typically
idor a timestamp) - Only supports forward/backward navigation, not random access
Practical Examples¶
Blog with Pagination¶
class BlogList(ListView):
model = Post
per_page = 10
ordering = "-published_at"
template_name = "blog/list.html"
context_object_name = "posts"
def get_queryset(self):
qs = Post.objects.filter(published=True)
category = self.request.GET.get("category")
if category:
qs = qs.filter(category=category)
return qs.order_by("-published_at")
Admin-Style Full Pagination¶
@app.route("/admin/users")
async def admin_users(request: Request) -> Response:
search = request.GET.get("q", "")
page_num = request.GET.get("page", "1")
qs = User.objects.order_by("name")
if search:
qs = qs.filter(name__icontains=search)
paginator = Paginator(qs, per_page=50, orphans=5)
try:
page = await paginator.page(page_num)
except InvalidPage:
page = await paginator.page(1)
return render(request, "admin/users.html", {
"page": page,
"search": search,
})
Batch Processing All Records¶
async def reindex_all_articles():
"""Process all articles in batches of 200."""
paginator = Paginator(Article.objects.all(), per_page=200)
async for page in paginator:
for article in page:
await search_index.update(article)
print(f"Reindexed page {page.number}/{page.num_pages}")
Reference¶
Imports¶
from hyperdjango.paginator import (
Paginator, # Main paginator class
Page, # Page dataclass
InvalidPage, # Base exception
PageNotAnInteger, # Non-integer page number
EmptyPage, # Page out of range
)
Paginator API Summary¶
| Method / Property | Returns | Description |
|---|---|---|
await page(number) |
Page |
Get page by 1-based number |
await get_count() |
int |
Total items (cached) |
await num_pages |
int |
Total page count |
await page_range |
range |
Range of valid page numbers |
async for page in paginator |
Page |
Iterate all pages |
Page API Summary¶
| Property | Returns | Description |
|---|---|---|
items |
list |
Items on this page |
number |
int |
Current page number (1-based) |
num_pages |
int |
Total pages |
count |
int |
Total items |
per_page |
int |
Items per page setting |
has_next |
bool |
Next page exists |
has_previous |
bool |
Previous page exists |
next_page_number |
int |
Next page (raises if last) |
previous_page_number |
int |
Previous page (raises if first) |
start_index |
int |
1-based first item index |
end_index |
int |
1-based last item index |
page_range |
range |
All valid page numbers |
len(page) |
int |
Item count on this page |
iter(page) |
iterator | Iterate items |
page[i] |
item | Index into items |
bool(page) |
bool |
True if page has items |