Пагинация: как забрать 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. Решение — отдавать порциями.
Но «отдавать порциями» — нюансная задача. Есть несколько подходов, и не все они одинаково хороши.
Пятая стратегия — 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 записей, выкинуть 100OFFSET 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, которого там не должно было быть. Аналогично, добавление записи во время итерации приводит к дубликатам: одна запись может попасть и в первую, и во вторую страницу.
Для 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).
Link header: пагинация по RFC 5988
Это 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 (с теми же проблемами стабильности).
Python-парсинг Link header
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
В работе 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, адаптируется под любой формат
Подводные камни
Итоги урока
Пагинация — обязательный механизм для любого 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 — «не положить сервер при этом».