Learning Platform
Глоссарий Troubleshooting
Урок 10.01 · 22 мин
Начальный
PaginationCursorKeysetLink headerGraphQL

Пагинация: как забрать 1 000 000 записей через API на 100 за раз

Реальные API почти никогда не возвращают всё разом. Запрос GET /users не отдаст вам миллион пользователей одним JSON-ответом — это убило бы и сервер, и клиента. Вместо этого API делит ответ на страницы: «вот 100 записей и подсказка, как получить следующие 100».

Пагинация — это не одна техника, а четыре разных стратегии, у каждой свой trade-off между простотой, производительностью и стабильностью. В этом уроке мы пройдём по offset, cursor, keyset, Link header и GraphQL connections, поймём, когда какую выбирать, и напишем Python-клиент, который правильно итерирует по любой из них.


Pagination patterns: offset, cursor, keyset на Python

Зачем вообще пагинация

Прямой ответ: производительность и опыт пользователя. Запрос «дайте мне всех customers» в БД с 10 миллионами строк убьёт сервер: память переполнится, transaction lock сломает другие запросы, клиент не сможет распарсить 1 ГБ JSON. Решение — отдавать порциями.

Но «отдавать порциями» — нюансная задача. Есть несколько подходов, и не все они одинаково хороши.

Четыре основные стратегии пагинации
Offset/Limit?offset=200&limit=100. Самый простой. SQL: SELECT ... LIMIT 100 OFFSET 200. Плюс -- random access ('дайте 5-ю страницу'). Минус -- медленный на глубине, нестабилен при изменениях
Cursor-based?cursor=abc123. Сервер выдаёт opaque token, клиент его шлёт обратно для следующей страницы. Стабильный к изменениям, нет random access
Keyset?after_id=200&limit=100. WHERE id > 200 ORDER BY id LIMIT 100. Очень быстрый на любой глубине, но требует sortable key
Link headerRFC 5988: ответ содержит заголовок Link с rel='next', 'prev', 'first', 'last' -- ссылки на другие страницы. Используется GitHub API, REST API многих платформ

Пятая стратегия — GraphQL Cursor Connections — это специфичная для GraphQL формализация cursor-based, рассмотрим отдельно.


Offset/Limit: простой, но с подвохами

Самая популярная стратегия в новых API: «дайте мне записи с N до N+100». В URL это выглядит как:

GET /users?offset=0&limit=100   # первая страница
GET /users?offset=100&limit=100 # вторая
GET /users?offset=200&limit=100 # третья

Часто вместо offset используется page:

GET /users?page=1&per_page=100
GET /users?page=2&per_page=100

Эти варианты эквивалентны: offset = (page - 1) * per_page.

Реализация на сервере

В SQL:

SELECT * FROM users
ORDER BY created_at DESC
LIMIT 100 OFFSET 200;

В Python с requests это итерация:

import requests

def fetch_all_offset(base_url: str, page_size: int = 100):
    offset = 0
    while True:
        response = requests.get(
            f"{base_url}/users",
            params={"offset": offset, "limit": page_size},
            timeout=10,
        )
        response.raise_for_status()
        data = response.json()
        yield from data["items"]
        if len(data["items"]) < page_size:
            break  # последняя страница
        offset += page_size

Проблема №1: deep paging медленный

В SQL LIMIT N OFFSET M означает: «прочитать M+N записей с диска, выкинуть первые M, вернуть N». Чем глубже offset, тем больше работы:

  • OFFSET 100 — прочитать 200 записей, выкинуть 100
  • OFFSET 100000 — прочитать 100100 записей, выкинуть 100000

Это линейная зависимость, и для 10-миллионной таблицы offset=9000000 — это секунды или минуты на запрос.

Проблема №2: нестабильность при изменениях

Допустим, вы итерируете по /users?offset=0&limit=100. Между первой и второй страницей кто-то удалил пользователя из первой сотни. Что произойдёт со второй страницей?

До удаления:
[user_1, user_2, ..., user_100, user_101, user_102, ..., user_200]
              page 1                       page 2

После удаления user_50, между запросами:
[user_1, user_2, ..., user_99, user_101, user_102, ..., user_201]
              page 1                       page 2

Когда вы запросите offset=100&limit=100, вы пропустите user_101 (он сдвинулся в первую страницу) и можете получить user_201, которого там не должно было быть. Аналогично, добавление записи во время итерации приводит к дубликатам: одна запись может попасть и в первую, и во вторую страницу.

Проблема нестабильности offset
t=0: page 1Берём offset=0, limit=100. Получили users 1-100
t=1: удалён user_50Между запросами кто-то удалил запись. Теперь user_101 сдвинулся на позицию 100
t=2: page 2Берём offset=100, limit=100. Но user_101 уже на позиции 100, и мы его ПРОПУСКАЕМ. В page 2 попадёт user_102 и далее

Для read-only данных или snapshot-ов это не проблема. Для активно изменяющихся данных (новые orders, обновляющиеся posts) — критично.

Когда выбрать offset

  • Маленькие коллекции (< 10к записей)
  • Read-only данные
  • Когда важен random access (UI с номерами страниц «1 2 3 … 50»)

Cursor-based: стабильный, но без random access

Cursor — это opaque (непрозрачный для клиента) токен, представляющий «позицию» в данных. Сервер выдаёт cursor вместе со страницей, клиент шлёт его обратно для следующей страницы.

GET /users?limit=100
# Response:
# {
#   "items": [...],
#   "next_cursor": "eyJsYXN0X2lkIjoxMDAsInRpbWVzdGFtcCI6MTcxNTAwMDAwMH0="
# }

GET /users?cursor=eyJsYXN0X2lkIjoxMDAsInRpbWVzdGFtcCI6MTcxNTAwMDAwMH0=&limit=100
# Response:
# {
#   "items": [...],
#   "next_cursor": "..."
# }

next_cursor — обычно base64 от JSON с состоянием («последний ID на странице», «timestamp»). Клиент не должен его парсить — это implementation detail сервера. Если cursor отсутствует или null — значит, страниц больше нет.

Реализация на сервере

Cursor реализуется через keyset под капотом (см. ниже) или через другие техники. Для клиента это неважно — он просто шлёт cursor и получает следующую порцию.

Преимущество: стабильность к изменениям

В отличие от offset, cursor запоминает «где мы были», а не «сколько пропустить». Если cursor говорит «последний id был 100», то добавление user_50 не сдвинет позицию: следующая страница начнётся с id > 100, корректно.

Недостаток: только sequential access

Нельзя «перейти на страницу 5» — только идти по next_cursor с начала. Для UI с пагинатором не подходит, для batch-обработки идеально.

Python-клиент с cursor

import requests

def fetch_all_cursor(base_url: str, limit: int = 100):
    cursor = None
    while True:
        params = {"limit": limit}
        if cursor:
            params["cursor"] = cursor
        response = requests.get(
            f"{base_url}/users",
            params=params,
            timeout=10,
        )
        response.raise_for_status()
        data = response.json()
        yield from data["items"]
        cursor = data.get("next_cursor")
        if cursor is None:
            break

# Использование
for user in fetch_all_cursor("https://api.example.com"):
    process(user)

Это идеальный паттерн для ETL: ленивый generator, обрабатывает по странице, не держит всё в памяти.


Keyset pagination: быстрый и стабильный

Keyset (он же seek pagination) — это явная версия cursor, где «позиция» — это значение sortable-ключа. Вместо opaque cursor сервер использует поле:

GET /users?after_id=100&limit=100
# Эквивалент SQL:
# SELECT * FROM users WHERE id > 100 ORDER BY id LIMIT 100

или для timestamp:

GET /events?after=2026-05-15T10:00:00Z&limit=100
# SELECT * FROM events WHERE created_at > '2026-05-15T10:00:00Z' ORDER BY created_at LIMIT 100

Почему keyset быстрый

В offset SQL читает M+N строк с диска. В keyset:

SELECT * FROM users WHERE id > 100 ORDER BY id LIMIT 100;

Если по id есть индекс (а он почти всегда есть как primary key), SQL seek в индексе на позицию id = 100 за O(log N), затем читает 100 следующих. Никакого пропуска данных. Это ~миллисекунды независимо от глубины.

Преимущество: стабильность

Как и cursor, keyset не страдает от добавления/удаления записей. WHERE id > 100 всегда означает «после позиции 100», независимо от количества записей до неё.

Недостаток: требует sortable key с уникальностью

Keyset работает только если есть поле для сортировки, по которому можно делать >. Обычно это id (auto-increment) или created_at + tiebreaker (если timestamp может совпадать). Для сортировки по non-unique полю (например, name) keyset усложняется — нужен compound key (name + id).

Когда выбрать keyset

  • Большие коллекции (миллионы строк), где offset медленный
  • Sequential access (батчи, ETL, scrolling-feed)
  • Стабильность важна (активно меняющиеся данные)

Cursor vs keyset — в чём разница

С точки зрения клиента — почти никакой:

  • Cursor: opaque token, не знаете что внутри
  • Keyset: явное поле, видите значение (after_id=100)

С точки зрения сервера — cursor более гибкий: сервер может менять реализацию (от keyset к снапшоту, или добавить дополнительное состояние) без поломки клиентов. Keyset — проще, но завязан на конкретное поле.

В новых API чаще выбирают cursor (Slack, Stripe, Twitter), в старых REST — keyset (часто since_id в Twitter v1, last_seen_id в legacy API).


Это REST-way: вся пагинация передаётся в HTTP-заголовке Link, описанном в RFC 5988. Используется GitHub API, многими публичными REST API.

GET /users?page=1&per_page=100

HTTP/1.1 200 OK
Link: <https://api.example.com/users?page=2&per_page=100>; rel="next",
      <https://api.example.com/users?page=10&per_page=100>; rel="last",
      <https://api.example.com/users?page=1&per_page=100>; rel="first"

Link содержит URLs с relation type:

  • next — следующая страница
  • prev — предыдущая
  • first — первая
  • last — последняя
  • иногда custom (например, rel="up" для родительского ресурса)

Преимущества

  • HATEOAS-style: клиент не вычисляет URL следующей страницы, а просто следует по ссылке. Сервер может менять структуру URL без поломки клиентов.
  • Стандарт: RFC 5988 знают все. Парсеры есть в каждой экосистеме.
  • Богатая семантика: first/last дают информацию о размере коллекции.

Недостатки

  • Парсинг Link header нетривиален: вложенные кавычки, запятые-разделители, опциональные параметры.
  • Под капотом обычно offset/page-based (с теми же проблемами стабильности).

requests имеет встроенную поддержку:

import requests

def fetch_all_link(start_url: str):
    url = start_url
    while url:
        response = requests.get(url, timeout=10)
        response.raise_for_status()
        yield from response.json()
        # response.links -- словарь {rel: {url, ...}}
        next_link = response.links.get("next")
        url = next_link["url"] if next_link else None

# Использование
for repo in fetch_all_link("https://api.github.com/users/octocat/repos"):
    print(repo["name"])

Прелесть в простоте: response.links["next"]["url"] — и вы знаете URL следующей страницы. Не надо разбираться с offset/cursor — сервер сам считает.


GraphQL Connections: формализация cursor

В GraphQL пагинация описана в спецификации Cursor Connections (релизована Facebook, теперь де-факто стандарт). Структура ответа:

query {
  users(first: 100, after: "cursor123") {
    edges {
      node {
        id
        email
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Что внутри:

  • edges — массив пар {node, cursor}. У каждой записи свой cursor.
  • node — собственно данные (User).
  • cursor — позиция этой записи (opaque). Используется для запроса страницы «после этого элемента».
  • pageInfo — метаданные: hasNextPage, endCursor (последний cursor на странице, удобно для next).

Главное отличие от REST cursor: каждая запись имеет cursor, не только страница в целом. Это позволяет «начать после конкретного элемента в середине».

async def fetch_all_graphql(client, page_size=100):
    cursor = None
    while True:
        query = """
        query Users($first: Int!, $after: String) {
          users(first: $first, after: $after) {
            edges {
              node { id email }
              cursor
            }
            pageInfo { hasNextPage endCursor }
          }
        }
        """
        result = await client.execute(query, {"first": page_size, "after": cursor})
        for edge in result["users"]["edges"]:
            yield edge["node"]
        if not result["users"]["pageInfo"]["hasNextPage"]:
            break
        cursor = result["users"]["pageInfo"]["endCursor"]

GraphQL чаще встречается в frontend-интеграциях. Для DE-инженера актуально, если работаете с GitHub GraphQL API, Shopify, Linear — все используют этот формат.


Что выбрать: matrix

Matrix: какую пагинацию выбрать
Маленькая коллекция, UI< 10к записей, нужны пронумерованные страницы для пользователя. Offset простой и приемлемый по производительности
ETL по большой коллекцииМиллионы строк, sequential access, активно меняется. Cursor или Keyset -- стабильность и скорость
Public REST APIХочется придерживаться стандартов, работать с разными клиентами. Link header даёт hypermedia-семантику
GraphQL APISpec говорит -- Cursor Connections. Не надо изобретать велосипед

В работе DE-инженера:

  • Чтение из чужих API: подстраивайтесь под то, что отдаёт API. Универсального паттерна нет.
  • Реализация своего API: для большинства случаев keyset или cursor — стандарт. Offset — только если действительно нужен random access.

Универсальный pattern: pagination в production-коде

Ниже шаблон, который покрывает большинство кейсов работы с пагинированными API. С обработкой ошибок и backoff (про backoff подробно в следующем уроке).

import time
from typing import Iterator, Optional, Any
import requests
from requests.exceptions import RequestException


def paginated_fetch(
    base_url: str,
    endpoint: str,
    headers: dict,
    page_size: int = 100,
    max_pages: Optional[int] = None,
) -> Iterator[dict]:
    """
    Универсальный пагинированный fetcher для cursor-based API.

    Yields каждый элемент по одному. Lazy -- не загружает всё в память.
    """
    cursor = None
    page = 0

    while max_pages is None or page < max_pages:
        params = {"limit": page_size}
        if cursor:
            params["cursor"] = cursor

        try:
            response = requests.get(
                f"{base_url}{endpoint}",
                headers=headers,
                params=params,
                timeout=30,
            )
            response.raise_for_status()
        except RequestException as exc:
            # В production -- retry с backoff (см. следующий урок)
            raise

        data = response.json()
        items = data.get("items", [])

        if not items:
            break

        yield from items

        cursor = data.get("next_cursor")
        if cursor is None:
            break

        page += 1
        # Уважение к серверу -- небольшая пауза между запросами
        # В production -- заменить на rate limiter (см. следующий урок)
        time.sleep(0.1)


# Использование
def etl_pipeline():
    headers = {"Authorization": "Bearer TOKEN"}
    total = 0
    for user in paginated_fetch(
        "https://api.example.com",
        "/v1/users",
        headers,
        page_size=200,
    ):
        # Обработка по одному
        save_to_db(user)
        total += 1
        if total % 1000 == 0:
            print(f"Processed {total} users")

    print(f"Done. Total: {total}")

Ключевые свойства:

  • Generator: используется yield from — код-потребитель не знает, что это пагинация, просто итерируется
  • Lazy: данные загружаются по мере потребления, не всё разом
  • Бессостоятельный: можно прервать на любой странице и в любом месте
  • Универсальный: меняя params и обработку response, адаптируется под любой формат

Подводные камни

Грабли пагинации
Off-by-one в offsetПутаница 0-based vs 1-based. ?page=1 vs ?page=0 для первой страницы -- зависит от API. Читайте docs
Бесконечные циклыЕсли сервер всегда возвращает next_cursor (даже когда страниц нет) -- бесконечный цикл. Защита: max_pages или sanity-check 'cursor не повторился'
Молчаливое усечениеСервер заявил 'limit=100', но вернул 50 -- это последняя страница, или ошибка? Проверять next_cursor, не количество элементов
Дубликаты в offsetПри активно меняющихся данных offset выдаёт дубликаты. Решение -- переход на cursor/keyset, или snapshot-таблица для итерации

Итоги урока

Пагинация — обязательный механизм для любого API с большими коллекциями. Четыре стратегии:

  • Offset/Limit — простой, поддерживает random access, медленный на глубине, нестабилен при изменениях. Для маленьких коллекций.
  • Cursor-based — opaque token, стабильный, без random access. Стандарт для современных API (Slack, Stripe).
  • Keyset (seek) — явный sortable key (after_id), очень быстрый на любой глубине. Лучший для больших ETL.
  • Link header (RFC 5988) — REST way, ссылки в заголовке. GitHub-style.

Для GraphQL — Cursor Connections (edges/node/pageInfo).

В Python: лениво через generators, использовать yield from. Никогда не загружать всю коллекцию в память.

В следующем уроке — что делать, когда сервер возвращает 429 Too Many Requests. Pagination — это «как забрать», rate limiting — «не положить сервер при этом».


Проверка знанийKnowledge check
Junior пишет ETL для забора 5 миллионов orders из API партнёра. Партнёр поддерживает offset/limit и keyset (?after_id=N). Какую стратегию выбрать и почему?
ОтветAnswer
Однозначно keyset (?after_id=N&limit=N). Почему: (1) Производительность -- на 5 миллионах строк deep paging через offset медленный (последние страницы будут отвечать секундами или таймаутиться), keyset везде O(log N). (2) Стабильность -- в больших коллекциях с активной записью offset даст дубликаты и пропуски (новые orders создаются во время итерации); keyset стабилен по WHERE id > N. (3) Возобновляемость -- если ETL упал на середине, для keyset нужно сохранить только last_seen_id и продолжить с него; для offset нужно знать общее количество и сложно прыгнуть в нужное место без пересчёта. (4) Уважение к серверу партнёра -- keyset запросы дешевле, не нагружают БД. Если только keyset недоступен (только offset), всё равно делать ETL, но: меньший batch size (100 вместо 1000), готовиться к дубликатам (idempotent insert через ON CONFLICT), мониторить прогресс по фактическому количеству, не по offset.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. В чём главный недостаток offset/limit пагинации при работе с большой коллекцией (миллионы строк)?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 3