Пагинация 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 до ответа сервера.

Продолжить на практике