Skip to main content

Keyset Pagination for Django

CI

Django's built-in pagination uses LIMIT/OFFSET. That works well for small offsets, but it gets progressively more expensive as the offset grows because the database still has to walk past the earlier rows.

This package implements keyset pagination, also called the seek method. Instead of asking for "page 200", the client asks for "the next page after this row" or "the previous page before this row". That keeps pagination efficient for large result sets, but it also means random page access and page-number iteration are not supported.

If you are new to the approach, the background articles from Markus Winand and Joe Nelson are worth reading.

Installation

Install the published package:

python -m pip install django-keyset-pagination-plus

The import path remains keyset_pagination.

Usage

In most class-based views you will want both the paginator and the included mixin, because Django's default pagination flow assumes integer page numbers.

from django.views.generic import ListView

from keyset_pagination.mixin import PaginateMixin
from keyset_pagination.paginator import KeysetPaginator


class EventList(PaginateMixin, ListView):
    paginator_class = KeysetPaginator
    paginate_by = 10
    queryset = Event.objects.order_by("-timestamp", "group", "pk")

If your queryset does not call .order_by(...), the paginator will also honor the model's Meta.ordering. That lets you rely on a model's default ordering without repeating it in every view:

class Event(models.Model):
    timestamp = models.DateTimeField()
    group = models.TextField()

    class Meta:
        ordering = ("-timestamp", "group", "pk")


paginator = KeysetPaginator(Event.objects.all(), 10)

A few important constraints:

  • The queryset must have a deterministic ordering. If you do not provide .order_by(...), the paginator falls back to the model's Meta.ordering.
  • If you explicitly clear ordering with .order_by(), the paginator raises ValueError.
  • Use a stable ordering. In practice that usually means appending a unique tiebreaker such as pk.
  • Ordering across related fields such as location__name is supported, but you should usually pair that with select_related() to avoid extra queries when page tokens are generated.
  • Only next/previous navigation is available. count, num_pages, and page-number iteration are intentionally unavailable.

Template usage

Cursor values are opaque JSON tokens built from the queryset ordering columns. Treat them as implementation details and pass them back unchanged.

{% if page_obj.has_previous %}
  <a href="?page={{ page_obj.previous_page_number }}">Previous page</a>
{% endif %}

{% if page_obj.has_next %}
  <a href="?page={{ page_obj.next_page_number }}">Next page</a>
{% endif %}

The same approach works well with GET forms when the list also has filters:

{% if page_obj.has_previous %}
  <button form="target-form"
          name="page"
          value="{{ page_obj.previous_page_number }}"
          type="submit">
    &larr; Previous page
  </button>
{% endif %}

{% if page_obj.has_next %}
  <button form="target-form"
          name="page"
          value="{{ page_obj.next_page_number }}"
          type="submit">
    Next page &rarr;
  </button>
{% endif %}

Cursor serialization handles values that do not round-trip cleanly through plain JSON types, including Decimal, datetime, date, time, and PostgreSQL range values. Decimal cursor values are parsed with Decimal, so precision is preserved even when the incoming token contains a bare JSON number.

Supported versions

The package supports Python 3.10+ and Django >=4.2,<6.1.

Continuous integration currently exercises Django 4.2, 5.2, and 6.0 on SQLite, plus PostgreSQL and MySQL integration environments for those Django releases.

Development

python -m pip install -e ".[dev,test]"
pre-commit install
tox -e py312-django52-sqlite
tox -p auto

Helpful shortcuts are also available through make:

make lint
make test
make test-all
make package

For local MySQL runs on macOS with Homebrew, install mysql-client and pkgconf. The MySQL tox environments default MYSQLCLIENT_CFLAGS and MYSQLCLIENT_LDFLAGS to Homebrew's mysql-client paths, and you can override them with environment variables if your local setup differs.

See schinckel.net: Keyset Pagination in Django for a longer walkthrough of the approach.

Release files for django-keyset-pagination-plus 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-keyset-pagination-plus 1.0.0
File Size Uploaded
django_keyset_pagination_plus-1.0.0.tar.gz 8.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-keyset-pagination-plus 1.0.0
File Interpreter ABI Platform
django_keyset_pagination_plus-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 18.2 kB

Release files / django_keyset_pagination_plus-1.0.0.tar.gz

Download URL django_keyset_pagination_plus-1.0.0.tar.gz
Size 8.9 kB
Tags Source
SHA-256 checksum
How to use checksums
0523ab77d82c39a630d068d1ac5b6f51736679b23329e463adbbef4a90e7c405
BLAKE2b-256 checksum
How to use checksums
8b8b06fa8b4f294e9a3e2be7bd142b6a3e12851a4b0ecbc2465f17a2a06d1d74
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.7

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Mar 27, 2026.

Transparency log

Release files / django_keyset_pagination_plus-1.0.0-py3-none-any.whl

Download URL django_keyset_pagination_plus-1.0.0-py3-none-any.whl
Size 9.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
be26d181a113473212060454d27149ebf5de7ae5e8b91e2c23deb656c800d776
BLAKE2b-256 checksum
How to use checksums
e2568910c825a54de23cc5312e46670882b5a74a9a4e48c432a6df870fdf5eae
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.7

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Mar 27, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

0.9.10

1 release file

0.9.9

2 release files

0.9.8

2 release files

0.9.6

2 release files

0.9.5

2 release files

0.9.4

2 release files

0.9.3

2 release files

0.9.2

2 release files

0.9.1

1 release file

0.9.0

2 release files

0.8.5

2 release files

0.8.4

2 release files

0.8.3

2 release files

0.8.2

2 release files

0.8.1

1 release file

0.8.0

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page