Пагинация FastAPI: ограничиваем limit и задаём устойчивый порядок
Каталог отдаёт все записи сразу. Пока их десять, это незаметно; после роста базы интерфейс получает большой ответ, а сервер выполняет ненужную работу. Вы добавляете limit и offset, но страницы начинают повторять записи с одинаковой датой. Для нормальной пагинации нужны одновременно предел размера, понятное смещение и однозначный порядок.
Опишите страницу как часть API
limit задаёт максимальное число элементов, offset — сколько пропустить в выбранном порядке. Не разрешайте отрицательное смещение и произвольный размер страницы: это ошибка договора и потенциально дорогой запрос. В примере разрешено от одной до ста записей, по умолчанию двадцать. Пустая страница после конца каталога возвращает 200 и пустой массив.
Сортировки только по created_at недостаточно, если несколько строк созданы в одну секунду. Добавьте уникальный id как второй ключ. В SQL это соответствует ORDER BY created_at, id. Такой порядок определён для неизменного набора данных. При вставках между запросами offset всё ещё сдвигается; для ленты с частыми изменениями лучше рассмотреть курсор по двум ключам.
Проверим соседние страницы
Маленький каталог в памяти позволяет увидеть механику до подключения SQL. Записи специально даны в другом порядке и имеют одинаковую дату. Срез выполняется только после сортировки. HTTP-проверки подтверждают состав страниц и отказы на опасных границах.
Сохраните весь блок в файл и запустите Python 3.11+ в отдельном окружении с пакетами fastapi, httpx. Для моделей используется Pydantic v2; отдельный сервер для TestClient не нужен.
from typing import Annotated
from fastapi import FastAPI, Query
from fastapi.testclient import TestClient
app = FastAPI()
records = [
{"id": 3, "created_at": "2026-11-01"},
{"id": 1, "created_at": "2026-11-01"},
{"id": 2, "created_at": "2026-11-01"},
]
@app.get("/products")
def products(limit: Annotated[int, Query(ge=1, le=100)] = 20,
offset: Annotated[int, Query(ge=0)] = 0):
ordered = sorted(records, key=lambda item: (item["created_at"], item["id"]))
return {"items": ordered[offset:offset + limit]}
with TestClient(app) as client:
first = client.get("/products?limit=2&offset=0").json()["items"]
second = client.get("/products?limit=2&offset=2").json()["items"]
assert [item["id"] for item in first + second] == [1, 2, 3]
assert client.get("/products?offset=50").json() == {"items": []}
for query in ("limit=0", "limit=101", "offset=-1", "limit=abc"):
assert client.get("/products?" + query).status_code == 422
print("pages:", [x["id"] for x in first], [x["id"] for x in second])Переносим правило в базу
В SQLAlchemy применяйте order_by, offset и limit к запросу до получения результатов. Сначала загрузить всю таблицу и сделать Python-срез — значит сохранить основную нагрузку, ради сокращения которой вводилась пагинация. Если в ответе нужен total, это отдельный подсчёт; не путайте число элементов текущей страницы с общим числом подходящих записей.
Для больших offset базе приходится пропускать много строк. Измерьте фактический запрос и план выполнения на своём объёме. Курсорная пагинация использует значения последней записи предыдущей страницы и позволяет продолжить выбранный порядок. Она меняет контракт навигации: произвольный переход на страницу номер тысяча уже не получается простым смещением.
Что проверить у интерфейса
Интерфейс должен сбрасывать offset при смене фильтра: старая третья страница может оказаться за концом нового результата. Сохраните в тесте фильтрацию и пагинацию вместе, когда появится реальный запрос. Для детерминированных тестов зафиксируйте начальный набор записей и проверяйте точные id, а не только длину ответа.
Ответ 422 для limit=101 сообщает клиенту исправить запрос. Молчаливо обрезать такое значение до ста тоже возможно, но это другой договор, который нужно документировать. Явные границы в Query попадают в OpenAPI и дают клиенту шанс сформировать допустимый запрос ещё до отправки.
Следующая практика
Продолжите в открытом уроке о query-параметрах: соберите фильтрацию каталога и проверьте значения из URL. Пагинация добавится к той же HTTP-границе, когда фильтр уже работает предсказуемо. Откройте урок о фильтрации каталога.
Чтобы фильтр и страница работали поверх настоящей базы, продолжите бесплатный курс FastAPI до SQLAlchemy и тестов каталога. Вы проверите уже весь путь от URL до ответа сервера.
Продолжить на практике
- FastAPI для начинающих: API с базой данных и тестами ↗
Соберёте свой первый полноценный backend на FastAPI: с базой данных, регистрацией, JWT-авторизацией и автотестами. 34 коротких урока и 140 заданий с проверкой — бесплатно.
