Skip to content

Pagination🔗

In this section, we'll cover the everything necessary for adding pagination to your GraphQL schema. Undine supports both offset and cursor based pagination.

Offset pagination🔗

Offset pagination is the simplest pagination method. It allows paginating a list by specifying an offset from the first item, and a limit for the number of items to return.

Offset pagination works well for lists where each item's index never changes, e.g., a list sorted by a timestamp or an auto-incrementing primary key. If this is not the case, you should use cursor based pagination instead, because changes in the middle of the list between page queries can cause items to be skipped or duplicated.

To add offset pagination to an Entrypoint, you need to wrap the QueryType, UnionType, or InterfaceType with the OffsetPagination class.

from undine import Entrypoint, QueryType, RootType
from undine.pagination import OffsetPagination

from .models import Task


class TaskType(QueryType[Task]): ...


class Query(RootType):
    paged_tasks = Entrypoint(OffsetPagination(TaskType))

This creates the following GraphQL types.

type TaskType {
  pk: Int!
  name: String!
  done: Boolean!
  createdAt: DateTime!
}

type Query {
  pagedTasks(
    offset: Int
    limit: Int
  ): [TaskType!]!
}

Offset pagination can also be used with many-related Fields.

from undine import Entrypoint, Field, QueryType, RootType
from undine.pagination import OffsetPagination

from .models import Person, Task


class PersonType(QueryType[Person]): ...


class TaskType(QueryType[Task]):
    assignees = Field(OffsetPagination(PersonType))


class Query(RootType):
    paged_tasks = Entrypoint(OffsetPagination(TaskType))

This creates the following GraphQL types.

type PersonType {
  pk: Int!
  name: String!
  email: Email!
  tasks: [TaskType!]!
}

type TaskType {
  pk: Int!
  name: String!
  done: Boolean!
  createdAt: DateTime!
  assignees(
    offset: Int
    limit: Int
  ): [PersonType!]!
}

type Query {
  pagedTasks(
    offset: Int
    limit: Int
  ): [TaskType!]!
}

Cursor pagination🔗

Cursor based pagination works by assigning items an opaque unique identifier called a "cursor". Pages can then be defined as starting before or after a given cursor.

A cursor identifies a row rather than a position in the list: it encodes the values the list is ordered by for the item it points to. Paginating "after" a cursor therefore means "give me the items that sort after this item", which makes cursor pagination resilient to items being added to or removed from the parts of the list that have already been read. Items are never skipped or delivered twice because of a change earlier in the list, which is what makes it a good fit for infinite scrolling and for lists that change while they are being read.

Two things follow from cursors being tied to the ordering:

  • A cursor is only valid for the ordering it was issued under. Replaying a cursor after changing the OrderSet arguments may return a Bad Request error, since no pagination scheme can say what comes "after" an item under an ordering that item was never sorted by.
  • The primary key is always appended to the ordering, so that no two items compare equal. If no ordering is requested, items are ordered by their primary key.

Additionally, cursor based pagination wraps the paginated items as Edge objects inside a Connection object. These objects contain additional information about the pagination state, such as the total count of items, cursor values, or whether a next or previous page exists. For more information on cursor pagination, see the GraphQL Cursor Connections Specification.

To add cursor pagination to a QueryType, UnionType or InterfaceType, Entrypoint, you need to wrap with the Connection class.

from undine import Entrypoint, QueryType, RootType
from undine.relay import Connection

from .models import Task


class TaskType(QueryType[Task]): ...


class Query(RootType):
    paged_tasks = Entrypoint(Connection(TaskType))

This creates the following GraphQL types.

type TaskType {
  pk: Int!
  name: String!
  done: Boolean!
  createdAt: DateTime!
}

type TaskTypeEdge {
  cursor: String!
  node: TaskType
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type TaskTypeConnection {
  totalCount: Int!
  pageInfo: PageInfo!
  edges: [TaskTypeEdge!]!
}

type Query {
  pagedTasks(
    after: String
    before: String
    first: Int
    last: Int
  ): TaskTypeConnection!
}

Querying this Entrypoint will return a response like this:

{
  "data": {
    "pagedTasks": {
      "totalCount": 3,
      "pageInfo": {
        "hasNextPage": false,
        "hasPreviousPage": false,
        "startCursor": "Y29ubmVjdGlvbjpUYXNrVHlwZTp7InBrIjoiMSJ9",
        "endCursor": "Y29ubmVjdGlvbjpUYXNrVHlwZTp7InBrIjoiMyJ9"
      },
      "edges": [
        {
          "cursor": "Y29ubmVjdGlvbjpUYXNrVHlwZTp7InBrIjoiMSJ9",
          "node": {
            "pk": 1,
            "name": "Task 1",
            "done": false
          }
        },
        {
          "cursor": "Y29ubmVjdGlvbjpUYXNrVHlwZTp7InBrIjoiMiJ9",
          "node": {
            "pk": 2,
            "name": "Task 2",
            "done": true
          }
        },
        {
          "cursor": "Y29ubmVjdGlvbjpUYXNrVHlwZTp7InBrIjoiMyJ9",
          "node": {
            "pk": 3,
            "name": "Task 3",
            "done": false
          }
        }
      ]
    }
  }
}

Similarly, cursor pagination can also be used with many-related Fields.

from undine import Entrypoint, Field, QueryType, RootType
from undine.relay import Connection

from .models import Person, Task


class PersonType(QueryType[Person]): ...


class TaskType(QueryType[Task]):
    assignees = Field(Connection(PersonType))


class Query(RootType):
    paged_tasks = Entrypoint(Connection(TaskType))

Note that for nested Connections, a single after or before cursor is shared by every parent item. Since a cursor points to a row and not to an index, each parent's list begins at the first item that sorts after that cursor, which can be a different position in each parent's list.

For Relay-compliant clients, see the Global Object IDs section for adding support for the Node interface.

Filtering and ordering🔗

If a FilterSet or an OrderSet has been added to a QueryType, their arguments will be added to the Entrypoint along with the pagination arguments for the specific pagination method. For example, for a Connection Entrypoint:

type Query {
  pagedTasks(
    after: String
    before: String
    first: Int
    last: Int
    filter: TaskFilterSet
    orderBy: [TaskOrderSet!]
  ): TaskTypeConnection!
}

Page size🔗

The default page size for all pagination methods is set by the PAGINATION_PAGE_SIZE setting. You can also use a different page size by using the page_size argument.

from undine import Entrypoint, QueryType, RootType
from undine.relay import Connection

from .models import Task


class TaskType(QueryType[Task]): ...


class Query(RootType):
    paged_tasks = Entrypoint(Connection(TaskType, page_size=20))

Setting page size to None will return all items in a single page.

Custom pagination strategies🔗

The default pagination strategies are accurate and performant for both top-level and nested fields (although calculating totalCount for nested Connections can be slow, since it requires a subquery for each parent item). Still, if you need to modify the pagination behavior, you can do so by providing a custom pagination handler class.

All Connections use the CursorPaginationHandler, which implements the cursor pagination described above. For a UnionType or an InterfaceType, a cursor encodes the ordering values the members share, the type name of the row, and then the ordering values of the query type the row came from. OffsetPagination uses the OffsetPaginationHandler instead, which pages with offset and limit.

from undine import Entrypoint, QueryType, RootType
from undine.relay import Connection, CursorPaginationHandler

from .models import Task


class CustomPaginationHandler(CursorPaginationHandler):
    """Custom pagination logic."""


class TaskType(QueryType[Task]): ...


class Query(RootType):
    paged_tasks = Entrypoint(Connection(TaskType, pagination_handler=CustomPaginationHandler))