GraphQL Pagination in Python: Offset, Cursor, and Relay Patterns

GraphQL gives clients the power to request exactly the data they need — but that flexibility has a catch. Without pagination, a single query against a field backed by 50,000 database rows returns all 50,000 to the client. This guide walks through every pagination pattern you will encounter in Python: offset-based, cursor-based, backwards pagination, page size selection, exponential backoff, cursor checkpointing, the gql client library, concurrent async loops, and building paginated servers with Graphene and the Relay Connection specification. Every code sample is self-contained and adapted from real API patterns.

GraphQL APIs can expose enormous datasets through a single query field. Consider an e-commerce platform with 50,000 products or a social network with millions of user posts. Returning all of that data at once would be impractical for both the server and the client. Pagination solves this by breaking results into manageable chunks, and GraphQL offers several strategies for doing it well.

Why GraphQL Needs Pagination

In a REST API, pagination is usually baked into the endpoint design with query parameters like ?page=2&per_page=25. GraphQL takes a different approach. Because the client controls the shape of the query, pagination parameters become arguments on individual fields rather than URL parameters.

Without pagination, a query that returns a list field could overwhelm the client with data it cannot render, saturate network bandwidth, and place unnecessary load on the database. Large APIs like GitHub's enforce this by requiring a first or last argument on every connection, with a maximum of 100 items per request.

The GraphQL Foundation's official documentation positions cursor-based pagination as the strongest of the available approaches, noting that offset and ID-based patterns can both be implemented within a cursor model — but not vice versa. The documentation describes cursor pagination as offering "additional flexibility if the pagination model changes."

Note

The two primary pagination strategies in GraphQL are offset-based (using limit and offset or skip) and cursor-based (using opaque cursors with first/after or last/before). Each has tradeoffs in complexity, performance, and consistency.

How to choose your pagination strategy
New pagination loop? Data changes frequently? No Users jump to arbitrary pages? Yes Offset-Based limit / offset args No Yes Cursor-Based first/after · pageInfo Large dataset Cursor-Based + Relay Connection spec

Offset-Based Pagination

Offset-based pagination is the simpler of the two approaches. It works the same way as SQL's LIMIT and OFFSET clauses: you tell the server how many items to return and how many to skip from the beginning of the result set.

Here is what a typical offset-based GraphQL query looks like:

# Offset-based pagination query
query GetProducts($limit: Int!, $offset: Int!) {
    products(limit: $limit, offset: $offset) {
        id
        name
        price
    }
}

To fetch the first page, you would send limit: 10, offset: 0. For the second page, limit: 10, offset: 10. For the third page, limit: 10, offset: 20, and so on.

The strength of offset-based pagination is that it allows jumping to any arbitrary page. If a user wants page 5, you can compute the offset directly as (page - 1) * page_size. However, this approach has two significant drawbacks. First, performance degrades with large offsets because the database still needs to scan past all the skipped rows. Second, if new records are inserted or deleted between requests, the offset window shifts, which can cause duplicate or missing items across pages.

Warning

Offset-based pagination is not recommended for large or frequently updated datasets. If records are added or removed between page requests, users may see duplicate items or miss items entirely because the offset window has shifted.

OFFSET-BASED
  • Simple math: (page-1) * size
  • Jump to any page number
  • Performance degrades at high offsets
  • Window drifts when data changes
  • Good for small, stable datasets
CURSOR-BASED
  • Opaque pointer, not a page number
  • Stable regardless of mutations
  • Uses DB index — efficient at scale
  • Cannot jump to arbitrary pages
  • Recommended for production APIs

Cursor-Based Pagination

Cursor-based pagination (also called keyset pagination) eliminates the consistency problems of offsets by using a pointer to a specific item rather than a numeric position. Instead of saying "skip 20 records," you say "give me the first 10 records after this specific cursor."

A cursor is typically an opaque, base64-encoded string that encodes information about a record's position -- often the record's primary key or a timestamp. Because the cursor points to a specific item rather than a position in the list, pagination stays consistent even when records are added or removed.

The Relay Cursor Connections Specification describes a cursor as an opaque string passed to the after argument to begin a page immediately following a given edge. The hasNextPage field in pageInfo signals whether more edges remain — when it is false, the spec says you have reached "the end of this connection."

# Cursor-based pagination query
query GetUsers($first: Int!, $after: String) {
    users(first: $first, after: $after) {
        edges {
            cursor
            node {
                id
                name
                email
            }
        }
        pageInfo {
            hasNextPage
            endCursor
        }
    }
}

The response includes a pageInfo object with two critical fields: hasNextPage tells you whether more data is available, and endCursor gives you the cursor value to pass as the after argument in your next request. This creates a clean loop: fetch a page, check hasNextPage, and if it is True, fetch again using endCursor as the new after value.

Cursor-based pagination also tends to perform better at scale. On the database side, a cursor query translates to something like WHERE id > cursor_value ORDER BY id LIMIT 10, which can use an index to jump directly to the right position without scanning past earlier rows.

Mental model: what the database actually sees

When your Python loop sends after: "cursor_abc", the server decodes that cursor to a row ID or timestamp, then issues something like SELECT * FROM items WHERE id > 5280 ORDER BY id LIMIT 50. The database jumps straight to row 5280 via the index. With offset pagination the equivalent is SELECT * FROM items ORDER BY id LIMIT 50 OFFSET 5000 — the database still scans 5,000 rows and throws them away before returning 50. At small offsets this is imperceptible. At large ones it becomes the bottleneck.

What does it mean when endCursor is null?

When hasNextPage is False, the GraphQL spec allows endCursor to be null. This catches a lot of people off guard the first time they see it, because the loop logic looks fine right up until the last page, where attempting to pass a null cursor as the after argument can cause unexpected behavior depending on how the server handles it.

The safe pattern is to stop before you ever try to use a null cursor. The pagination loops in this article check hasNextPage before assigning the cursor for the next iteration, which handles this correctly. But if you are ever building your own loop from scratch, watch for this case explicitly:

# Correct: always guard the cursor assignment
page_info = result["data"]["items"]["pageInfo"]

if not page_info["hasNextPage"]:
    break

# endCursor is only safe to use when hasNextPage is True
cursor = page_info["endCursor"]

Reading endCursor only when hasNextPage is True guarantees it will always be a usable string rather than None. The spec formally states that when hasNextPage is False, the value of endCursor is undefined — some servers return the last cursor, others return null, and relying on either behavior makes your code brittle.

Pro Tip

Cursors should be treated as opaque strings by the client. Even though they are often base64-encoded values, do not decode or construct them manually. The server may change the encoding format at any time.

When the last page is returned, hasNextPage is False. At that point, the Relay spec leaves endCursor undefined — some servers return the last valid cursor, others return null. If your loop always passes endCursor back as the after argument, one of two things happens depending on the server: it returns an empty page and loops forever, or it throws an error because null is not a valid cursor value. Either way, guarding with if not hasNextPage: break before reading the cursor is the only safe pattern.

Paginating Backwards with last and before

The Relay specification defines four pagination arguments: first, after, last, and before. The article so far has only used the forward pair. The reverse pair works symmetrically but traverses the result set in the opposite direction, which matters more often than people expect.

Consider a comment thread where you want to display the most recent comments first and load earlier ones as the user scrolls up -- the same pattern used by many messaging apps and social feeds. Forward pagination from the beginning of the list gives you the oldest items first. To start at the newest end and walk backwards, you use last and before:

# Reverse cursor-based pagination query
query GetComments($postId: ID!, $last: Int!, $before: String) {
    post(id: $postId) {
        comments(last: $last, before: $before) {
            edges {
                cursor
                node {
                    id
                    body
                    createdAt
                }
            }
            pageInfo {
                hasPreviousPage
                startCursor
            }
        }
    }
}

Here, last controls how many items to return from the end of the list, and before accepts a cursor to tell the server where to stop. The pageInfo fields you need to check are hasPreviousPage (not hasNextPage) and startCursor (not endCursor). The Python loop looks nearly identical to the forward version, just with those two fields swapped:

import requests

API_URL = "https://your-api.example.com/graphql"

QUERY = """
query ($postId: ID!, $last: Int!, $before: String) {
    post(id: $postId) {
        comments(last: $last, before: $before) {
            edges {
                cursor
                node {
                    id
                    body
                    createdAt
                }
            }
            pageInfo {
                hasPreviousPage
                startCursor
            }
        }
    }
}
"""


def fetch_comments_backwards(post_id, page_size=20):
    """Walk backwards through comments, newest first."""
    all_comments = []
    cursor = None

    while True:
        variables = {
            "postId": post_id,
            "last": page_size,
            "before": cursor,
        }

        response = requests.post(
            API_URL,
            json={"query": QUERY, "variables": variables},
        )
        response.raise_for_status()
        result = response.json()

        if "errors" in result:
            raise Exception(f"GraphQL errors: {result['errors']}")

        comments_data = result["data"]["post"]["comments"]
        # Edges come back in ascending order even with last/before,
        # so reverse them to get newest-first ordering in our list.
        all_comments.extend(
            reversed([edge["node"] for edge in comments_data["edges"]])
        )

        if not comments_data["pageInfo"]["hasPreviousPage"]:
            break

        cursor = comments_data["pageInfo"]["startCursor"]

    return all_comments
Note

Not every GraphQL API implements all four pagination arguments. Before using last and before, check the API's schema or documentation to confirm they are supported. Sending last to a server that only handles first will usually return an error rather than silently falling back.

Consuming Paginated APIs with the requests Library

The Python requests library is the most straightforward way to interact with a GraphQL API. Since GraphQL queries are just HTTP POST requests with a JSON body, you do not need a specialized GraphQL client to get started.

Authentication patterns

The examples in this article use GitHub's Bearer token pattern, but not every GraphQL API authenticates the same way. Knowing which scheme an API uses before you write your pagination loop saves a frustrating round of trial and error.

  • Bearer tokens are the most common pattern. Pass the token in an Authorization header: {"Authorization": "Bearer your_token"}. GitHub, Shopify (Admin API), and most modern APIs use this format.
  • API keys in a custom header are common with simpler services. The header name varies by provider — it might be X-API-Key, x-hasura-admin-secret, or something entirely provider-specific. Check the API's documentation for the exact header name.
  • API keys as query parameters appear occasionally on older or simpler APIs. These are passed directly in the URL rather than in headers and are generally considered less secure because URLs can appear in server logs.
  • OAuth 2.0 access tokens use the same Bearer header format as static tokens, but the token itself is short-lived and obtained through an authorization flow. If you are paginating an OAuth-protected API in an automated script, you will need to handle token refresh when the access token expires mid-loop.
  • No authentication applies to public GraphQL APIs and local development servers. In those cases, you can skip the headers argument entirely or pass an empty dict.

Here is a complete example that paginates through all repositories in a GitHub organization using cursor-based pagination:

import requests

GITHUB_TOKEN = "ghp_your_token_here"
API_URL = "https://api.github.com/graphql"
HEADERS = {"Authorization": f"Bearer {GITHUB_TOKEN}"}


def build_query(after_cursor=None):
    """Build a GraphQL query with optional cursor for pagination."""
    after_value = f'"{after_cursor}"' if after_cursor else "null"
    return f"""
    query {{
        organization(login: "python") {{
            repositories(first: 50, after: {after_value}) {{
                pageInfo {{
                    hasNextPage
                    endCursor
                }}
                nodes {{
                    name
                    stargazerCount
                    updatedAt
                }}
            }}
        }}
    }}
    """


def fetch_all_repos():
    """Paginate through all repositories and collect them."""
    all_repos = []
    has_next_page = True
    cursor = None

    while has_next_page:
        query = build_query(cursor)
        response = requests.post(
            API_URL,
            json={"query": query},
            headers=HEADERS,
        )
        response.raise_for_status()
        data = response.json()

        repos_data = data["data"]["organization"]["repositories"]
        page_info = repos_data["pageInfo"]

        all_repos.extend(repos_data["nodes"])
        has_next_page = page_info["hasNextPage"]
        cursor = page_info["endCursor"]

        print(f"Fetched {len(repos_data['nodes'])} repos "
              f"(total: {len(all_repos)})")

    return all_repos


repos = fetch_all_repos()
print(f"\nTotal repositories: {len(repos)}")

The pattern here is simple. The build_query function generates a GraphQL query string, inserting the cursor value when one is provided. The fetch_all_repos function runs a while loop that keeps fetching pages until hasNextPage is False. Each iteration extracts the endCursor from pageInfo and passes it into the next query.

Using Variables Instead of String Interpolation

String interpolation works, but GraphQL variables are the cleaner approach. Variables separate the query structure from the input data, making queries safer and easier to maintain:

import requests

GITHUB_TOKEN = "ghp_your_token_here"
API_URL = "https://api.github.com/graphql"
HEADERS = {"Authorization": f"Bearer {GITHUB_TOKEN}"}

QUERY = """
query ($org: String!, $first: Int!, $after: String) {
    organization(login: $org) {
        repositories(first: $first, after: $after) {
            pageInfo {
                hasNextPage
                endCursor
            }
            nodes {
                name
                stargazerCount
            }
        }
    }
}
"""


def fetch_all_repos(org_name, page_size=50):
    """Paginate through repos using GraphQL variables."""
    all_repos = []
    cursor = None

    while True:
        variables = {
            "org": org_name,
            "first": page_size,
            "after": cursor,
        }

        response = requests.post(
            API_URL,
            json={"query": QUERY, "variables": variables},
            headers=HEADERS,
        )
        response.raise_for_status()
        result = response.json()

        if "errors" in result:
            raise Exception(f"GraphQL errors: {result['errors']}")

        repos_data = result["data"]["organization"]["repositories"]
        all_repos.extend(repos_data["nodes"])

        if not repos_data["pageInfo"]["hasNextPage"]:
            break

        cursor = repos_data["pageInfo"]["endCursor"]

    return all_repos

This version defines the query once as a constant with variable placeholders ($org, $first, $after) and passes the variable values in a separate dictionary. The GraphQL server handles substitution safely, and you avoid any risk of injection or formatting issues.

Paginating nested connections

The examples so far paginate a single top-level connection. Real queries frequently nest connections — for example, fetching repositories and their issues in a single request. Each nested connection has its own pageInfo and must be paginated independently.

The challenge is that the outer cursor and the inner cursor are separate. You cannot use a single loop to advance both simultaneously. The practical approach for nested data is to paginate the outer connection fully, then for any outer node that has more inner results, run a second pagination loop using that node's ID as an anchor:

import requests

GITHUB_TOKEN = "ghp_your_token_here"
API_URL = "https://api.github.com/graphql"
HEADERS = {"Authorization": f"Bearer {GITHUB_TOKEN}"}

# Outer query: fetch repos with a preview of their issues
REPOS_WITH_ISSUES_QUERY = """
query ($org: String!, $first: Int!, $after: String) {
    organization(login: $org) {
        repositories(first: $first, after: $after) {
            pageInfo { hasNextPage endCursor }
            nodes {
                name
                # Fetch only the first batch of issues here.
                # If hasNextPage is true for issues, paginate them separately.
                issues(first: 25, states: OPEN) {
                    pageInfo { hasNextPage endCursor }
                    nodes { number title }
                }
            }
        }
    }
}
"""

# Inner query: fetch remaining issues for a specific repo
MORE_ISSUES_QUERY = """
query ($org: String!, $repo: String!, $first: Int!, $after: String) {
    repository(owner: $org, name: $repo) {
        issues(first: $first, after: $after, states: OPEN) {
            pageInfo { hasNextPage endCursor }
            nodes { number title }
        }
    }
}
"""


def fetch_all_issues_for_repo(org, repo_name, initial_after):
    """Paginate remaining issues for a repo that had hasNextPage True."""
    all_issues = []
    cursor = initial_after

    while True:
        variables = {"org": org, "repo": repo_name, "first": 50, "after": cursor}
        response = requests.post(API_URL, json={"query": MORE_ISSUES_QUERY,
                                                "variables": variables},
                                 headers=HEADERS)
        response.raise_for_status()
        result = response.json()
        issues = result["data"]["repository"]["issues"]
        all_issues.extend(issues["nodes"])

        if not issues["pageInfo"]["hasNextPage"]:
            break
        cursor = issues["pageInfo"]["endCursor"]

    return all_issues
Warning

Fetching deeply nested connections for every item in a large outer result set can generate a very large number of API requests and burn through rate limits quickly. Always consider whether you need nested data for every outer node, or whether you can filter and only follow up on the nodes that actually matter for your use case.

Choosing a Page Size

The examples in this article use page sizes of 10 and 50 without much comment. In practice, the number you pass to first or limit has a real impact on how the pagination loop performs, and the right choice depends on what the API allows and what your code is doing with the results.

Using a larger page size reduces the total number of round trips to the server. If you need all 10,000 records from an endpoint that allows up to 100 per page, fetching 100 at a time means 100 requests. Fetching 10 at a time means 1,000. The difference in total elapsed time can be significant, especially against remote APIs with non-trivial latency. On the other hand, large pages mean larger payloads and more memory usage in your Python process. If your resolver is hitting a database, a page of 500 objects also means a heavier query and a longer response time for each individual request.

A few practical guidelines:

  • Check the server's maximum. Many APIs enforce a hard cap. GitHub's GraphQL API allows a maximum of 100 per connection per the GitHub GraphQL docs. Shopify's Admin GraphQL API and Storefront API both allow up to 250 per connection per the Shopify pagination docs. Sending a value above the cap returns an error rather than silently clamping to the maximum.
  • Start with 50 to 100 for bulk collection jobs. When you are collecting all records for an export or a data pipeline, larger pages reduce request count and tend to be faster overall.
  • Use smaller pages (10 to 25) for UI-driven pagination. If the user is navigating through results page by page, there is no benefit to fetching 100 records when the interface shows 10. Smaller pages also mean faster first-page response times.
  • Tune around rate limits. If you are constrained by a rate limiter measured in requests per hour, fewer larger requests are cheaper than many small ones. If the rate limiter measures by data volume or compute units, the opposite may apply.
Pro Tip

Make page size a parameter in your function rather than a hard-coded constant. def fetch_all_repos(org_name, page_size=50) is easier to tune than hunting through query strings. If you are building a reusable client, exposing page size as a configuration option lets callers adjust it without touching the internals.

Handling Errors and Rate Limits in Pagination Loops

Pagination loops are different from one-off requests because a failure partway through means you have already done some work and may need to retry from the right position. There are three categories of problems worth handling explicitly: HTTP errors, GraphQL-level errors embedded in a 200 response, and rate limiting.

GraphQL errors inside a 200 response

GraphQL APIs return HTTP 200 even when something goes wrong. The error information lives in a top-level errors array alongside data. A successful response.raise_for_status() call does not mean the query worked. Always check for errors before trying to read data:

result = response.json()

# GraphQL errors come back as HTTP 200 with an "errors" key.
# Check before accessing "data" or you will get a KeyError.
if "errors" in result:
    for err in result["errors"]:
        print(f"GraphQL error: {err.get('message', err)}")
    raise Exception("Query returned errors, stopping pagination.")

# Only now is it safe to read data
repos_data = result["data"]["organization"]["repositories"]

Partial responses

GraphQL allows a response to include both data and errors at the same time. This is called a partial response — the server completed part of the query but encountered a problem with a specific field. For example, a query might successfully return pageInfo and edges but fail to resolve a metadata field on one of the nodes, resulting in a response where that field is null and an entry appears in errors explaining why.

Here is what a partial response looks like in practice:

# Example partial response from a GraphQL server:
# {
#   "data": {
#     "users": {
#       "edges": [{"node": {"id": "1", "name": "Alice", "profileMetadata": null}},
#                 {"node": {"id": "2", "name": "Bob",   "profileMetadata": null}}],
#       "pageInfo": {"hasNextPage": true, "endCursor": "abc123"}
#     }
#   },
#   "errors": [
#     {
#       "message": "profileMetadata resolver timed out",
#       "locations": [{"line": 5, "column": 9}],
#       "path": ["users", "edges", 0, "node", "profileMetadata"]
#     }
#   ]
# }

# Handling partial responses in a pagination loop:
result = response.json()

errors = result.get("errors")
data = result.get("data")

if errors and not data:
    # Total failure — no usable data at all
    raise Exception(f"GraphQL query failed: {errors}")

if errors and data:
    # Partial response — log warnings and continue with the data we have
    for err in errors:
        path = " -> ".join(str(p) for p in err.get("path", []))
        print(f"Warning: partial error at {path}: {err.get('message')}")

# Proceed using data normally
users_data = data["users"]

The right behavior depends on your use case. For a bulk data pipeline, logging the error and continuing is usually appropriate — losing a single optional field on a few records is better than aborting the entire job. For a UI-driven request where every field is critical, treating any error as a hard failure makes more sense. The key point is that you need to check both errors and data explicitly rather than assuming their presence or absence implies anything about the other.

Rate limiting

Pagination loops by definition make repeated requests in quick succession. APIs that enforce rate limits -- including GitHub's GraphQL API -- will start returning errors or HTTP 429 responses if you exceed the allowed request rate. The safest approach is to build a retry with exponential backoff into your loop:

import time
import requests

MAX_RETRIES = 4
BACKOFF_BASE = 2  # seconds


def post_with_retry(url, payload, headers):
    """POST to a GraphQL endpoint with exponential backoff on rate limit errors."""
    for attempt in range(MAX_RETRIES):
        response = requests.post(url, json=payload, headers=headers)

        if response.status_code == 429:
            # Respect the Retry-After header when the server provides it
            retry_after = int(response.headers.get("Retry-After", BACKOFF_BASE ** attempt))
            print(f"Rate limited. Retrying in {retry_after}s (attempt {attempt + 1})")
            time.sleep(retry_after)
            continue

        response.raise_for_status()
        return response.json()

    raise Exception(f"Request failed after {MAX_RETRIES} attempts")

For the GitHub GraphQL API specifically, the rate limit state is available in a rateLimit field you can include in any query. Adding it to your pagination query lets you check how many requests you have left before hitting the wall:

QUERY = """
query ($org: String!, $first: Int!, $after: String) {
    rateLimit {
        remaining
        resetAt
    }
    organization(login: $org) {
        repositories(first: $first, after: $after) {
            pageInfo {
                hasNextPage
                endCursor
            }
            nodes {
                name
                stargazerCount
            }
        }
    }
}
"""

# In your loop, check remaining after each response:
rate_limit = result["data"]["rateLimit"]
if rate_limit["remaining"] < 10:
    print(f"Rate limit low ({rate_limit['remaining']} remaining). "
          f"Resets at {rate_limit['resetAt']}")
    # Optionally sleep until reset or raise an informative exception
Warning

An unbounded pagination loop that fetches all records is fine for small datasets, but think carefully before running it against a production API with hundreds of thousands of records. Prefer fetching only what you need and stopping early when possible, rather than building loops that always exhaust the full dataset.

Resumable Pagination with Checkpointing

A pagination loop that runs for several minutes over thousands of records has a practical problem: if it crashes halfway through -- due to a network error, an unexpected API response, or a process being killed -- you lose your position and have to start from the beginning. For small datasets this is a minor inconvenience. For large ones it wastes time, burns API rate limit, and in some cases means re-processing data you have already handled.

The solution is to save your cursor to disk after each successful page. On the next run, the loop reads that saved cursor and resumes from where it left off rather than starting over.

import json
import os
import requests

GITHUB_TOKEN = "ghp_your_token_here"
API_URL = "https://api.github.com/graphql"
HEADERS = {"Authorization": f"Bearer {GITHUB_TOKEN}"}
CHECKPOINT_FILE = "pagination_checkpoint.json"

QUERY = """
query ($org: String!, $first: Int!, $after: String) {
    organization(login: $org) {
        repositories(first: $first, after: $after) {
            pageInfo {
                hasNextPage
                endCursor
            }
            nodes {
                name
                stargazerCount
            }
        }
    }
}
"""


def load_checkpoint():
    """Return the saved cursor, or None if no checkpoint exists."""
    if os.path.exists(CHECKPOINT_FILE):
        with open(CHECKPOINT_FILE) as f:
            return json.load(f).get("cursor")
    return None


def save_checkpoint(cursor):
    """Persist the current cursor to disk."""
    with open(CHECKPOINT_FILE, "w") as f:
        json.dump({"cursor": cursor}, f)


def clear_checkpoint():
    """Remove the checkpoint file once the job completes successfully."""
    if os.path.exists(CHECKPOINT_FILE):
        os.remove(CHECKPOINT_FILE)


def fetch_all_repos_resumable(org_name, page_size=50):
    """Paginate through repos, resuming from checkpoint if one exists."""
    all_repos = []
    cursor = load_checkpoint()

    if cursor:
        print(f"Resuming from checkpoint cursor: {cursor[:20]}...")

    while True:
        variables = {"org": org_name, "first": page_size, "after": cursor}
        response = requests.post(
            API_URL,
            json={"query": QUERY, "variables": variables},
            headers=HEADERS,
        )
        response.raise_for_status()
        result = response.json()

        if "errors" in result:
            raise Exception(f"GraphQL errors: {result['errors']}")

        repos_data = result["data"]["organization"]["repositories"]
        all_repos.extend(repos_data["nodes"])

        if not repos_data["pageInfo"]["hasNextPage"]:
            # Job complete — remove the checkpoint so the next run starts fresh
            clear_checkpoint()
            break

        cursor = repos_data["pageInfo"]["endCursor"]
        save_checkpoint(cursor)

    return all_repos

Each successful page saves endCursor to a JSON file. If the process is interrupted on page 47 out of 200, the next run reads the checkpoint, skips straight to page 48, and continues from there. When the loop exhausts all pages, the checkpoint file is deleted so the next full run starts fresh.

A few things worth noting about this pattern. The checkpoint records the cursor for the next page, not the page just fetched -- this means if a page download succeeds but the checkpoint write fails, you will re-fetch that page rather than skip it. Duplicate handling is something you may want to add if that is a concern for your use case. Also, checkpoints are tightly coupled to the specific query and sort order. If you change the query parameters between runs, a saved cursor may no longer be valid.

Note

For short-lived scripts checkpointing may be unnecessary. For long-running jobs, scheduled data pipelines, or any loop that could plausibly be interrupted, the additional few lines of code are cheap insurance.

Mental model: checkpointing as a bookmark

Think of cursor checkpointing the same way you would a physical bookmark. The book (dataset) doesn't change. The bookmark (cursor file) simply records where you stopped. When you pick up the book again, you open to that page and continue — you don't re-read from page one. The only wrinkle: if you swap to a different edition (change the query or sort order), your old bookmark may point to the wrong place.

If you save the cursor before processing the page, a crash after saving means the next run skips that page entirely — the data is lost silently. The safer order is: fetch the page, process and persist the data, then save the checkpoint. This way a crash before saving the checkpoint just means you re-fetch that page on the next run and handle a duplicate, which is usually recoverable. The code in this article saves the cursor after extending the list, which follows this safer ordering.

Using the gql Client for Pagination

The gql library is a full-featured GraphQL client for Python that supports query validation, multiple transport protocols, and async operations. It provides a more structured way to interact with GraphQL APIs compared to raw requests calls.

from gql import Client, gql
from gql.transport.aiohttp import AIOHTTPTransport

# Configure the transport with your API endpoint and auth
transport = AIOHTTPTransport(
    url="https://api.github.com/graphql",
    headers={"Authorization": "Bearer ghp_your_token_here"},
)

# Create the client (fetch_schema_from_transport enables validation)
client = Client(
    transport=transport,
    fetch_schema_from_transport=True,
)

# Define the paginated query using gql()
REPOS_QUERY = gql("""
    query GetRepos($first: Int!, $after: String) {
        organization(login: "python") {
            repositories(first: $first, after: $after) {
                pageInfo {
                    hasNextPage
                    endCursor
                }
                nodes {
                    name
                    stargazerCount
                }
            }
        }
    }
""")


def fetch_all_repos_gql():
    """Paginate through repos using the gql client."""
    all_repos = []
    cursor = None

    while True:
        params = {"first": 50, "after": cursor}
        result = client.execute(REPOS_QUERY, variable_values=params)

        repos_data = result["organization"]["repositories"]
        all_repos.extend(repos_data["nodes"])

        if not repos_data["pageInfo"]["hasNextPage"]:
            break

        cursor = repos_data["pageInfo"]["endCursor"]

    return all_repos

The gql library parses the query at definition time with the gql() function, catching syntax errors before any request is sent. When fetch_schema_from_transport is set to True, the client can also validate your queries against the server's schema, which helps catch field name typos and type mismatches during development.

gql v4 API Note

In gql version 4, the gql() function returns a GraphQLRequest object rather than a bare DocumentNode. The variable_values argument to execute() still works as shown in the examples here, but it is marked deprecated in v4 — the preferred pattern is to pass variables inside the GraphQLRequest at definition time. The examples in this article are compatible with both gql v3 and v4 using the variable_values parameter.

Pro Tip

When fetch_schema_from_transport is True, the client makes an introspection query on startup to fetch the schema. If you are running in a tight loop or a short-lived script, set this to False to skip that extra round trip.

Async Pagination with the gql Client

The gql library's async support is worth using directly rather than leaving as a footnote. If you are building a service that paginates through multiple GraphQL endpoints at once, running each pagination loop concurrently with asyncio can cut total elapsed time dramatically compared to running them sequentially.

Here is the same pagination loop from the previous section rewritten as a proper async function:

import asyncio
from gql import Client, gql
from gql.transport.aiohttp import AIOHTTPTransport

REPOS_QUERY = gql("""
    query GetRepos($org: String!, $first: Int!, $after: String) {
        organization(login: $org) {
            repositories(first: $first, after: $after) {
                pageInfo {
                    hasNextPage
                    endCursor
                }
                nodes {
                    name
                    stargazerCount
                }
            }
        }
    }
""")


async def fetch_all_repos_async(org_name, token, page_size=50):
    """Async pagination through all repositories for an organization."""
    transport = AIOHTTPTransport(
        url="https://api.github.com/graphql",
        headers={"Authorization": f"Bearer {token}"},
    )

    all_repos = []
    cursor = None

    async with Client(transport=transport, fetch_schema_from_transport=False) as session:
        while True:
            params = {"org": org_name, "first": page_size, "after": cursor}
            result = await session.execute(REPOS_QUERY, variable_values=params)

            repos_data = result["organization"]["repositories"]
            all_repos.extend(repos_data["nodes"])

            if not repos_data["pageInfo"]["hasNextPage"]:
                break

            cursor = repos_data["pageInfo"]["endCursor"]

    return all_repos


async def fetch_multiple_orgs(orgs, token):
    """Paginate through multiple organizations concurrently."""
    tasks = [fetch_all_repos_async(org, token) for org in orgs]
    results = await asyncio.gather(*tasks)
    # results is a list of lists, one per org
    return dict(zip(orgs, results))


# Run it
if __name__ == "__main__":
    orgs = ["python", "django", "pallets"]
    TOKEN = "ghp_your_token_here"
    all_results = asyncio.run(fetch_multiple_orgs(orgs, TOKEN))
    for org, repos in all_results.items():
        print(f"{org}: {len(repos)} repositories")

The key difference from the synchronous version is that the Client is used as an async context manager (async with), and each query execution uses await. The asyncio.gather call runs the three pagination loops concurrently. Each loop makes its own sequential requests internally, but the three loops make progress in parallel, so you are not waiting for one organization's results to finish before the next one starts.

A practical note: even when running concurrent pagination loops, each loop is still sequential within itself. You cannot safely parallelize the individual pages of a single cursor-based sequence because each page depends on the cursor from the previous one. Concurrency helps when you have independent sequences to run -- different organizations, different queries, different resources.

Building a Paginated Server with Graphene

If you are building a GraphQL API in Python rather than consuming one, Graphene is one of the established libraries for defining schemas with a code-first approach. It has been a foundational part of the Python GraphQL ecosystem for years and includes built-in support for the Relay Connection pattern. Here is how to set up basic offset-based pagination on a server using Graphene:

import graphene


class Product(graphene.ObjectType):
    """A single product in the catalog."""
    id = graphene.ID()
    name = graphene.String()
    price = graphene.Float()


# Sample data (in production this would come from a database)
PRODUCTS = [
    Product(id=str(i), name=f"Product {i}", price=round(9.99 + i, 2))
    for i in range(1, 101)
]


class Query(graphene.ObjectType):
    products = graphene.List(
        Product,
        limit=graphene.Int(default_value=10),
        offset=graphene.Int(default_value=0),
    )

    def resolve_products(self, info, limit, offset):
        """Return a slice of the product list."""
        return PRODUCTS[offset : offset + limit]


schema = graphene.Schema(query=Query)

# Test the query
result = schema.execute("""
    query {
        products(limit: 5, offset: 0) {
            id
            name
            price
        }
    }
""")

print(result.data)

This example defines a Product type and a products query field that accepts limit and offset arguments. The resolver simply slices the Python list. In a real application, you would pass these values to your database query or ORM.

Why the Naive Graphene Resolver Breaks at Scale

The Graphene examples above work correctly, but they paper over a problem that will hurt you in production. Both the offset resolver and the Relay resolver return a Python list from memory. The offset resolver slices it, and Graphene's relay.ConnectionField slices it automatically. That means the code is fetching every record from your data source every time -- it just returns a subset of it to the client.

This is fine for in-memory lists in a tutorial, but once your resolver calls a database, an ORM, or an external API, returning everything and slicing in Python throws away all the benefits of pagination. The database does the expensive work of fetching all 50,000 rows; Python slices them down to 10; only 10 go to the client. Your server still paid for 50,000.

A production Graphene resolver passes the pagination arguments into the query rather than resolving after the fact. Here is how that looks with a SQLAlchemy-style ORM:

import graphene


class Product(graphene.ObjectType):
    id = graphene.ID()
    name = graphene.String()
    price = graphene.Float()


class Query(graphene.ObjectType):
    products = graphene.List(
        Product,
        limit=graphene.Int(default_value=10),
        offset=graphene.Int(default_value=0),
    )

    def resolve_products(self, info, limit, offset):
        # Push limit and offset into the database query, not into Python.
        # This way the database returns only the rows you actually need.
        # Replace db.session.query(...) with your actual ORM or query builder.
        return (
            db.session.query(ProductModel)
            .order_by(ProductModel.id)
            .offset(offset)
            .limit(limit)
            .all()
        )

The same principle applies to the Relay connection resolver. Graphene calls resolve_all_products with the full **kwargs containing first, after, and so on. Rather than ignoring those arguments and returning everything, a well-written resolver decodes the cursor and passes the equivalent bounds to the database. Graphene's built-in cursor encoding is base64-wrapped plain text in the format arrayconnection:INDEX, which you can decode if you want to convert it back to an offset for a SQL query.

The N+1 problem in GraphQL resolvers

Pushing pagination into the database query is the right first step, but there is a second performance problem that shows up the moment your type has nested fields resolved from a different data source: the N+1 query problem.

Consider a Product type with a category field that requires a separate database lookup. If you return 50 products, Graphene will call the resolve_category resolver once per product — 50 separate queries just to populate that one field, plus the original query to fetch the products. The number of queries grows linearly with the page size.

The standard solution in Python GraphQL stacks is a dataloader. A dataloader batches individual resolver calls that happen within the same execution context into a single query:

from promise import Promise
from promise.dataloader import DataLoader


class CategoryLoader(DataLoader):
    """Batch-load categories by their IDs in a single query."""

    def batch_load_fn(self, category_ids):
        # One query for all IDs instead of one query per product
        categories = (
            db.session.query(CategoryModel)
            .filter(CategoryModel.id.in_(category_ids))
            .all()
        )
        category_map = {str(c.id): c for c in categories}
        return Promise.resolve([category_map.get(str(cid)) for cid in category_ids])


# In your Graphene resolver, use the loader instead of a direct query:
class Product(graphene.ObjectType):
    id = graphene.ID()
    name = graphene.String()
    category = graphene.Field(lambda: Category)

    def resolve_category(self, info):
        # Graphene batches all pending resolve_category calls before executing
        return info.context["loaders"].category.load(self.category_id)
Note

The promise package used here is a Python implementation of the Promise pattern designed for use with Graphene. If you move to Strawberry, it has built-in dataloader support through strawberry.dataloader without needing promise as a dependency.

Pro Tip

If you are building a production GraphQL server with Python, look at Strawberry as an alternative to Graphene. Strawberry uses Python type hints for schema definition and has active maintenance and commercial support. It is also the recommended library for FastAPI projects specifically — Starlette, the toolkit FastAPI is built on, no longer includes a Graphene integration, which means Graphene-based servers cannot use FastAPI without a third-party adapter. Strawberry integrates with FastAPI, Django, Flask, and other frameworks out of the box.

With a page size of 1, you fetch one product. If that product has a category field that triggers a separate query, you end up with 2 queries total (1+1). Annoying, but barely noticeable. With a page size of 100, you now have 101 queries — 1 for the products list, then 100 individual category lookups fired in rapid succession. A dataloader collapses those 100 lookups into a single WHERE id IN (...) query, bringing the total back down to 2 regardless of page size.

Relay Connection Pattern

The Relay Cursor Connections Specification, originally published by Meta for the Relay JavaScript client, defines a standardized way to implement cursor-based pagination in GraphQL. The specification is publicly available and not exclusive to Relay — any GraphQL server or client can implement it. In practice, GitHub, Shopify, and many other large GraphQL APIs follow this pattern regardless of what client their users run.

The Relay Cursor Connections Specification defines the connection model as a standard mechanism for both slicing a result set into pages and providing the client with the cursors and continuation signals needed to navigate between them.

The pattern introduces three key structural concepts: connections, edges, and nodes.

A connection represents the paginated relationship between two objects. It contains a list of edges, where each edge holds a cursor (the position marker) and a node (the data record itself). The connection also includes a pageInfo object with four standardized fields: hasNextPage, hasPreviousPage, startCursor, and endCursor. The spec formally requires that startCursor and endCursor correspond to the first and last nodes in the current edge set, respectively.

Here is how to implement this pattern in Graphene using its built-in Relay support:

Mental model: connection, edge, node — Russian dolls

Think of a connection as the whole paginated list. Each item in that list is an edge — a wrapper that holds a cursor (the position marker) and a node (the actual data). The connection also carries a pageInfo object sitting at the top level. So the nesting is: connection → edges[] → { cursor, node }. This extra layer of wrapping over a simple list exists precisely to attach cursor information to each individual position, which is what makes backwards pagination and arbitrary resumption possible.

import graphene
from graphene import relay


class ProductNode(graphene.ObjectType):
    """A product exposed as a Relay node."""

    class Meta:
        interfaces = (relay.Node,)

    name = graphene.String()
    price = graphene.Float()


class ProductConnection(relay.Connection):
    """Relay connection wrapping ProductNode edges."""

    class Meta:
        node = ProductNode

    total_count = graphene.Int()

    def resolve_total_count(root, info):
        return len(PRODUCTS)


# Sample data
PRODUCTS = [
    {"id": i, "name": f"Widget {i}", "price": round(4.99 + i * 0.5, 2)}
    for i in range(1, 201)
]


class Query(graphene.ObjectType):
    all_products = relay.ConnectionField(ProductConnection)

    def resolve_all_products(self, info, **kwargs):
        """Resolve the connection by returning product nodes."""
        return [
            ProductNode(
                id=p["id"],
                name=p["name"],
                price=p["price"],
            )
            for p in PRODUCTS
        ]


schema = graphene.Schema(query=Query)

# Query the first 3 products
result = schema.execute("""
    query {
        allProducts(first: 3) {
            totalCount
            edges {
                cursor
                node {
                    id
                    name
                    price
                }
            }
            pageInfo {
                hasNextPage
                endCursor
            }
        }
    }
""")

for edge in result.data["allProducts"]["edges"]:
    node = edge["node"]
    print(f"{node['name']}: ${node['price']}")

Graphene handles the cursor generation, edge wrapping, and pageInfo construction automatically when you use relay.ConnectionField. The resolver only needs to return the full list of items, and Graphene takes care of slicing based on the first, after, last, and before arguments.

The Relay pattern also supports a totalCount field on the connection, which tells the client how many records exist in total. This is useful for rendering pagination controls that show "Page 1 of 20" in a user interface.

Warning

totalCount looks cheap to add but is expensive to compute at scale. On a SQL database, returning an exact count alongside a paginated result typically requires a COUNT(*) query over the entire table — even when the page only returns 10 rows. For tables with millions of records, this can be significantly slower than the paginated fetch itself. If you only need an approximate count, some databases (PostgreSQL's pg_class statistics, for example) can provide estimates without a full scan. If you do not need a count at all, leave totalCount out of your schema or make it a separate, explicitly requested field rather than always-on.

Note

The Relay Connection specification is not exclusive to the Relay JavaScript client. Any GraphQL client can consume Relay-style connections. GitHub, Shopify, and many other large GraphQL APIs use this pattern regardless of what client frameworks their users prefer.

Choosing Between Offset and Cursor Pagination

Offset pagination is appropriate when your dataset is small to medium-sized, records are not changing frequently, and users need to jump to arbitrary page numbers — for example, a search results page where "go to page 5" is a meaningful user action. The math is simple and the implementation is shallow.

Cursor pagination is the better default for anything else: larger datasets, data that changes frequently, feeds, activity streams, or any use case where consistency across page boundaries matters. Because cursors point to specific items rather than numeric positions, records added or deleted between requests do not disturb the window. The official GraphQL documentation recommends cursor-based pagination as the most flexible and future-proof approach, noting that offset and ID-based patterns can be emulated within a cursor model if needed — but the reverse is not true.

A useful rule of thumb: if you would be comfortable with your pagination surviving a write operation happening between page 1 and page 2, offset is fine. If you would not, use cursors.

A search results page with numbered pages ("Page 1 of 47") is a genuine case for offset pagination — users expect to jump to page 12 directly, which requires a numeric offset. The data in a search index also tends to be mostly stable between a user's page requests. Similarly, admin dashboards that show "rows 201–300" benefit from offset so the interface can display which slice of the total the user is viewing. The key signal is: if the feature genuinely requires displaying a current position in a total count and allowing arbitrary jumps, offset makes sense. If the feature is a scrolling feed, a data pipeline, or anything where consistency matters more than addressability, cursor wins.

Key Takeaways

  1. Offset-based pagination is simple to implement with limit and offset arguments, but it degrades in performance and consistency with large or frequently changing datasets.
  2. Cursor-based pagination uses opaque cursor tokens and pageInfo to traverse results. It provides stable results and leverages database indexes for efficient queries, making it the preferred approach for production APIs.
  3. Guard against null endCursor. Only read endCursor when hasNextPage is True. When hasNextPage is False, the spec leaves endCursor undefined — some servers return null, others return the last cursor value, and relying on either makes your code brittle.
  4. Backwards pagination uses last and before instead of first and after. Check hasPreviousPage and startCursor when walking a result set in reverse.
  5. Page size matters. Larger pages reduce request count but increase payload size. Check the API's maximum (GitHub: 100, Shopify: 250), make page size a configurable parameter, and tune it based on whether you are building a bulk pipeline or a UI-driven interface.
  6. GraphQL errors arrive as HTTP 200. Always check for an errors key in the response before accessing data. A response can contain both data and errors simultaneously — treat that as a partial response, not a binary success or failure.
  7. Nested connections require independent loops. Each nested connection has its own pageInfo and cursor. Paginate the outer connection first, then follow up with separate loops for any inner connections that have more data. Be mindful of the request volume this can generate against rate-limited APIs.
  8. Checkpointing saves your position. For long-running loops, persist the cursor after each successful page so you can resume from the right spot if the job is interrupted. Delete the checkpoint file when the job completes.
  9. The requests library is all you need to consume paginated GraphQL APIs in Python. Use GraphQL variables instead of string interpolation for cleaner, safer queries.
  10. The gql library adds schema validation, async support, and multiple transport options on top of the basic request pattern. Use async with Client and asyncio.gather to run independent pagination loops concurrently. Note that gql v4 changed how variables are passed — see the compatibility note in that section.
  11. Graphene's Relay support handles cursor generation, edge wrapping, and pageInfo construction automatically — but the default resolver pattern loads all records into memory. Production resolvers must push pagination arguments into the database query. Watch for the N+1 problem when your types have nested fields; use a dataloader to batch those lookups.
  12. totalCount is expensive. Adding it to a Relay connection typically requires a COUNT(*) over the full table, even when the page returns only a handful of rows. Omit it unless you genuinely need it, and consider approximate counts for very large tables.
  13. The Relay Connection pattern with connections, edges, and nodes is an industry standard used by GitHub, Shopify, and other major APIs — you do not need to use the Relay JavaScript client to benefit from it.
  14. For new Python GraphQL servers, consider Strawberry over Graphene. Strawberry uses Python type hints, has active maintenance and commercial support, and integrates natively with FastAPI — where Graphene no longer has a first-party integration.

Pagination is one of those patterns that seems straightforward until you hit edge cases at scale. The mistakes that cause the pain in production are: choosing offset pagination for a dataset that grows or changes, not checking the errors key in GraphQL responses before reading data, mishandling null cursors on the last page, writing Graphene resolvers that return entire tables to Python before slicing, and ignoring the N+1 problem when types have nested fields. Avoiding those from the start — and reaching for cursor pagination, explicit error checks, database-side limits, and dataloaders as defaults — saves significant debugging time later. The code patterns in this article cover the common cases; the sources listed below cover the edge cases that only show up at real scale.

Sources & Further Reading

The technical claims in this article are grounded in the following primary sources. All library version details and API limits were verified against official documentation as of March 2026.

How the concepts connect
Cursor-Based first/after · last/before pageInfo hasNextPage · endCursor Relay Spec connections · edges · nodes Checkpointing cursor → disk → resume Graphene relay.ConnectionField N+1 / DataLoader batch resolver calls Rate Limiting exponential backoff Async Pagination asyncio.gather · gql drives implements enables standardizes