---
title: "Проекции, сортировка, лимиты"
description: "Проекции, сортировка и лимиты в mongo из Go: SetSort с bson.D, SetProjection, SetLimit и SetSkip в FindOptions — пагинация каталога без сюрпризов."
course: "MongoDB в Go: официальный драйвер mongo-driver v2"
url: "https://golangmaster.ru/courses/mongo-driver/lessons/13-proekcii-sort-limit/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Проекции, сортировка, лимиты

*Курс «MongoDB в Go: официальный драйвер mongo-driver v2», урок 13 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/mongo-driver/lessons/13-proekcii-sort-limit/*

FindOptions в golang — это три самых частых настроителя Find: проекции отдают только нужные поля, сортировка задаёт порядок, лимиты и skip режут выборку. Разберём SetSort с bson.D, SetProjection, SetLimit и SetSkip — и почему мультиключевую карту в сортировке драйвер отвергает намеренно.

Каталог товаров живёт списками: лента на главной, страница категории, результаты поиска. Документ товара при этом тяжёлый — описания на трёх языках, характеристики, галерея из двадцати ссылок. Но карточке в списке нужны имя, цена и ссылка на картинку: тянуть полные документы на каждую строку ленты — значит гонять по сети в десять раз больше данных, чем нужно, и распаковывать поля, которые тут же выбросятся. Вторая задача списков — порядок: «сначала дешёвые», «новые сверху», «по рейтингу». Третья — объём страницы: двадцать позиций за раз, вторая страница — ещё двадцать. Все три задачи решаются на стороне базы опциями Find, и в этом уроке разберём их через options.Find() — единственный билдер, который понадобится: SetProjection для полей, SetSort для порядка, SetLimit и SetSkip для объёма. Отдельная развязка урока — сортировка: она принимает упорядоченные документы, и драйвер проверяет это ещё до отправки запроса. К концу урока соберёте страницу каталога, которая не гоняет лишние байты и не ломается на третьей странице.

### FindOptions: один билдер на всё

coll.Find(ctx, filter, opts...) принимает вариадик опций, а собираются они конструктором options.Find(): каждый Set-метод возвращает тот же билдер, поэтому настройки пишутся цепочкой в один вызов. Поля билдера — прямое отражение серверных опций команды find, и типы в них честные: SetLimit и SetSkip принимают int64, SetBatchSize — int32, SetProjection и SetSort — any, потому что там уезжают документы. Рядом живут и настройки реже: SetCollation задаёт правила сравнения строк — с ней сортировка по имени идёт по правилам языка, а не по кодам символов; SetComment помечает запрос в логах и профайлере сервера — та же метка, что у delete-вызовов одиннадцатого урока. Пустой билдер равен отсутствию опций: сервер вернёт все совпавшие документы целиком в естественном порядке. Опции работают поверх фильтра из восьмого и четырнадцатого уроков: сначала база отбирает совпавшие документы, потом сортирует их, отсекает skip, отдаёт limit и проецирует поля — читать эти этапы полезно именно в таком порядке, потому что так их исполняет сервер. Опции Find и FindOne — разные семейства (FindOptions и FindOneOptions), но базовые настройки у них общие: у FindOne есть свои SetSort, SetProjection и SetSkip, поэтому «самый дорогой товар» берётся FindOne с SetSort по цене убывания. Чего у FindOne нет — SetLimit и SetBatchSize: одиночному документу лимит не нужен, сервер и так вернёт максимум один.

### Страница каталога одним вызовом

```go
coll := client.Database("shop").Collection("products")

cursor, err := coll.Find(
        ctx,
        bson.M{"category": "клавиатуры"},
        options.Find().
                SetSort(bson.D{{Key: "price", Value: -1}}).
                SetSkip(20).
                SetLimit(10),
)
if err != nil {
        return fmt.Errorf("page of products: %w", err)
}

var page []Product
if err := cursor.All(ctx, &page); err != nil {
        return fmt.Errorf("decode page: %w", err)
}
```

Опции собираются цепочкой билдера: SetSort выстраивает цены по убыванию, SetSkip пропускает две страницы, SetLimit отдаёт третью. Сортировка — bson.D: мультиключевую карту драйвер отвергает намеренно, потому что порядок полей в сортировке значим. Cursor.All из седьмого урока декодирует страницу в срез структур и закрывает курсор сам.

### Сортировка: bson.D обязателен

SetSort принимает документ вида {поле: направление}, где 1 — по возрастанию, -1 — по убыванию. Составная сортировка пишется списком пар: цена по возрастанию, внутри равных цен — новизна сверху: bson.D{{Key: "price", Value: 1}, {Key: "created_at", Value: -1}}. Здесь и живёт главное правило урока: Sort — упорядоченный аргумент, и мультиключевую bson.M в него передать нельзя. Драйвер проверяет это до отправки: SetSort(bson.M{"price": 1, "name": -1}) вернёт ErrMapForOrderedArgument с текстом multi-key map passed in for ordered parameter sort. Причина в природе карт: обход map в Go не упорядочен, и «price до name» или наоборот драйвер выбрать не может — а от порядка полей зависит результат сортировки равных значений. Проверка стоит в collection.go рядом с marshal аргумента, и это одна из тех клиентских ошибок, которые экономят час отладки. Всё, что упорядочено по смыслу — сортировки, подсказки индексов, конвейеры, — пишите через bson.D; карты остаются для фильтров, где порядок не играет роли. Проверка стоит не только в Find: FindOne с картой-сортировкой вернёт *SingleResult с той же готовой ошибкой до похода в сеть — добраться до неё можно уже методом Err, без Decode. Зато карта с одним ключом легальна: ограничение срабатывает только на две и более пары — драйвер не может упорядочить то, что сам не упорядочивает.

### Главные настройки FindOptions

| Метод | Тип аргумента | Что делает |
| --- | --- | --- |
| SetSort | any — обычно bson.D | порядок выдачи: 1 по возрастанию, -1 по убыванию |
| SetLimit | int64 | максимум документов в результате |
| SetSkip | int64 | сколько совпавших пропустить перед выдачей |
| SetProjection | any — bson.M или bson.D | какие поля отдавать: включение или исключение |
| SetBatchSize | int32 | документов в одном сетевом батче курсора |

### Проекции: отдаём только нужные поля

Проекция — документ, который говорит серверу, какие поля включить в ответ: bson.M{"name": 1, "price": 1} вернёт только эти два поля. Проекции в mongo из Go живут в SetProjection, и у них два режима. Режим включения: перечисляете поля с 1 — сервер отдаст их и _id, который добавляется по умолчанию; отключить его можно отдельно — bson.M{"name": 1, "_id": 0}. Режим исключения: перечисляете поля с 0 — вернутся все остальные; так отрезают тяжёлое описание от карточки: bson.M{"description": 0}. Смешивать включения и исключения нельзя — сервер вернёт ошибку, единственное исключение из правила и есть _id. Путь заходит и во вложенные документы: {"specs.color": 1} достанет цвет из specs, не трогая остальные характеристики. Для массивов есть отдельный оператор $slice: {"comments": {"$slice": 3}} отдаст максимум три первых элемента массива в каждом документе — проекция умеет не только брать и бросать, но и резать объём. Практическая выгода двойная: меньше сетевого трафика и меньше работы декодера — непроектированные поля даже не распаковываются. Но есть и цена: структура при декодировании получит нули там, где поля нет, — и это следующий коллаут.

> **Живое место: проекция без поля — ноль в структуре.** Ленту товаров писали с проекцией {name: 1, price: 1}, потом в структуру Product добавили поле sku, а проекцию не тронули — и вот отчёт о остатках молча видит нули. Decode не выдаёт ошибки: поля нет в документе, значит, в структуре нулевое значение. Правило: проекция живёт рядом с моделью и запросом, а новое поле структуры — повод проверить все проекции этого типа. Поймать просто: декодированная структура с нулевым sku при живом документе в базе — это всегда проекция, а не данные.

### Лимиты и skip: пагинация страницами

SetLimit(10) обрезает выдачу до десяти документов, SetSkip(20) пропускает первые двадцать совпавших — вместе это привычная пагинация: страница N начинается с skip = (N-1) * размер, размер задаёт лимит. Пара тонкостей делает пару честной. Первая: без SetSort лимит отдаст «какие-то десять» — сервер не обещает порядок без сортировки, и на разных запросах состав страницы может отличаться; лимит всегда ходит в паре с сортировкой. Типы аргументов напоминают о себе на переменных: SetLimit и SetSkip принимают int64 — литерал 20 компилятор приведёт сам, а переменную int придётся конвертировать явным int64(page); нормальная плата за честные типы в опциях. Вторая: по докам драйвера отрицательный лимит означает «вернуть документы в одном батче» — экзотика для отладки, в обычном коде не встречается. Третья — цена skip: серверу нужно пройтись и отбросить все пропускаемые документы, поэтому страница 500 по 20 позиций стоит заметно дороже первой; для глубоких листаний лучше диапазон по _id из двенадцатого урока. А ещё лимит — инструмент стоимости: «топ-10 по цене» — это сортировка плюс SetLimit(10), и десять документов сервер и не будет отправлять.

### Пагинация с подсчётом страниц

```go
total, err := coll.CountDocuments(ctx, bson.M{"category": "клавиатуры"})
if err != nil {
        return fmt.Errorf("count: %w", err)
}

cursor, err := coll.Find(
        ctx,
        bson.M{"category": "клавиатуры"},
        options.Find().
                SetProjection(bson.M{"name": 1, "price": 1, "_id": 0}).
                SetSort(bson.D{{Key: "price", Value: 1}}).
                SetLimit(20),
)
if err != nil {
        return fmt.Errorf("page: %w", err)
}
```

CountDocuments возвращает число совпадений для того же фильтра — из него считают количество страниц, а Find с проекцией отдаёт карточки: только name и price, причём _id отключён явно. Лёгкие документы экономят трафик и время декодирования, а тяжёлые описания останутся в базе до момента, когда пользователь откроет конкретную карточку. Итого два запроса на страницу — честная плата за знание общего числа.

### Правила проекций одним списком

- Включение: перечисляете нужные поля с 1 — остальное не уезжает с сервера.
- Исключение: перечисляете лишние с 0 — удобно отрезать тяжёлые поля.
- Смешивать 1 и 0 нельзя, единственное исключение — отключение _id.
- _id приходит по умолчанию с любым включением — отключайте явно, если не нужен.
- Непроектированное поле декодируется нулём — проекции меняются вместе со структурой.
- Для тяжёлых документов выгода измеряется трафиком и временем Decode, а не «скоростью базы».

### Проверка: карта в сортировке

**Вопрос:** options.Find().SetSort(bson.M{"price": 1, "name": -1}) — что произойдёт?

- Сортировка применится по двум полям
- ✅ Драйвер вернёт ErrMapForOrderedArgument ещё до отправки на сервер
- Сортировка применится только по price
- Ограничение действует только в v1 драйвера

Sort — упорядоченный аргумент: мультиключевая карта для него запрещена, и драйвер возвращает ошибку multi-key map passed in for ordered parameter sort на клиенте. bson.D со срезом пар сохраняет порядок полей — сортировки пишут через него. Проверка руками: карта с одним ключом легально проходит — ошибка приходит только на две и более пары, потому что драйверу нечего упорядочивать в одной паре.

### Проверка: включение и _id

**Вопрос:** Проекция bson.M{"name": 1, "_id": 0} — что вернёт сервер?

- Ошибку: смешивать 1 и 0 нельзя
- ✅ Только поле name — _id отключён явно
- Все поля, кроме name и _id
- name и _id

Включающая проекция отдаёт перечисленные поля; _id — единственное поле, которое можно отключить рядом с включениями. Остальные смешивания включений и исключений сервер отклоняет ошибкой — для «всего кроме» есть режим исключения.

### Что вернёт сортировка с лимитом?

```go
// в коллекции товары с ценами: 100, 300, 200
cursor, _ := coll.Find(ctx, bson.M{},
        options.Find().
                SetSort(bson.D{{Key: "price", Value: -1}}).
                SetLimit(2))
var top []bson.M
cursor.All(ctx, &top)
fmt.Println(len(top), top[0]["price"])
```

**Что выведет программа?**

- ✅ 2 300
- 3 100
- 2 100
- 1 300

Сортировка по убыванию ставит 300 первым, лимит обрезает выдачу до двух документов: длина среза 2, первый элемент 300. Остальное сервер даже не отправит — limit работает на стороне базы, а не при декодировании.

### Проекция или узкая структура: кто фильтрует

Декодирование в структуру уже фильтрует результат: поля документа, которых нет в структуре, просто игнорируются — эта механика была в пятом уроке. Возникает законный вопрос: зачем проекция, если структура и так отрежет лишнее. Ответ — в том, где происходит работа. Без проекции сервер отправляет полные документы по сети, и только декодер выбрасывает поля: трафик потрачен, батчи курсора распухли, а тяжёлое описание распаковалось, чтобы умереть в мусоре. С проекцией отбрасывание происходит до отправки: меньше байт, быстрее батчи, ниже нагрузка на декодер. Узкая структура — зеркало того же приёма в обратную сторону: отдельный тип с пятью полями для ленты и полный тип для карточки делают код честнее, чем один жирный Product на все случаи. В паре они дают максимум: узкая структура описывает форму выдачи, проекция обещает серверу, что остальное можно не отправлять. Узкая структура ещё и тестируется дешевле: функция ленты принимает и возвращает пять полей, и тестовый литерал не тащит двадцать. Что выбирать при bson.M-результатах — проекция единственная защита от перекачки данных.

> **SetBatchSize против SetLimit.** Две опции путают, хотя они про разное. SetLimit режет результат: сколько документов всего вернёт запрос. SetBatchSize режет транспорт: сколько документов приедет в одном сетевом батче курсора до следующего getMore — дефолтные 101 в первом батче и 16 МБ на партию уже были в седьмом уроке. Лимит — про объём ответа, батч — про размер поездок. Правило выбора: сначала лимит — сколько данных вообще нужно; батч — только когда видно, что документы крупные и поездок мало.

### Соберите запрос с опциями

Восстановите цепочку: сортировка по убыванию цены, лимит и исключение _id из проекции.

```go
cursor, err := coll.Find(
        ctx,
        bson.M{"category": cat},
        options.Find().
                Set___(bson.D{{Key: "price", Value: -1}}).
                Set___(10).
                SetProjection(bson.M{"name": 1, "___": 0}),
)
```

Подсказка: Порядок — SetSort, объём — SetLimit, а единственное поле, которое можно исключить рядом с включениями, — _id.

Правильные ответы в пропусках: `Sort`, `Limit`, `_id`

### Соберите метод пагинации

Расставьте строки метода по порядку: вычисление skip, запрос с опциями, обработка ошибки, декодирование страницы.

Правильный порядок строк:

1. `func (r *ProductRepo) Page(ctx context.Context, page, perPage int64) ([]Product, error) {`
2. `  skip := (page - 1) * perPage`
3. `  cursor, err := r.coll.Find(ctx, bson.M{},`
4. `    options.Find().SetSort(bson.D{{Key: "_id", Value: 1}}).`
5. `      SetSkip(skip).SetLimit(perPage))`
6. `  if err != nil { return nil, err }`
7. `  var out []Product`
8. `  return out, cursor.All(ctx, &out)`
9. `}`

### Карточки: опции Find

- **FindOptions** — Билдер options.Find() с цепочкой Set-методов: SetSort, SetLimit, SetSkip, SetProjection, SetBatchSize. Пустой билдер = дефолтное поведение Find.
- **SetSort** — Принимает упорядоченный документ: bson.D{{Key: "price", Value: -1}}. Мультиключевая bson.M отвергается клиентской ошибкой ErrMapForOrderedArgument.
- **SetProjection** — Включение (поля с 1) или исключение (с 0); смешивать нельзя, кроме _id: 0 рядом с включениями. Непроектированные поля декодируются нулями.
- **SetLimit и SetSkip** — Лимит обрезает результат, skip отбрасывает начало: страница N — skip = (N-1)*perPage. Без сортировки лимит отдаёт произвольные документы.
- **ErrMapForOrderedArgument** — Клиентская ошибка «multi-key map passed in for ordered parameter sort»: порядок в карте не гарантирован, а сортировке он нужен. Лечится bson.D.
- **SetBatchSize** — Размер сетевого батча курсора (int32), не объём результата. Дефолт — 101 документ в первой партии; крутится вместе с курсором седьмого урока.

Опции Find работают и в паре с другими глаголами: сортировка из этого урока — обязательная пара к лимиту, а проекция встретится в агрегациях пятнадцатого урока в виде этапа $project. Дальше список усложняется по смыслу: в уроке про [$and, $or и $regex](/courses/mongo-driver/lessons/14-slozhnye-filtry/) соберём фильтры поиска, которые уходят в тот же Find вместе с опциями, а [итоговый проект курса](/courses/mongo-driver/lessons/25-itogovyj-proekt/) соберёт каталог целиком: фильтры, сортировка, пагинация и счётчики на одной странице. До этого стоит закрепить механику на живой базе: песочница сайта — тематическая программа на чистом Go, поэтому запросы с опциями гоняйте локально в mongosh с explain — там видно, как сервер отрезал лимит и взял ли индекс под сортировку.

## Коротко: что запомнить

- options.Find() — единый билдер: SetProjection для полей, SetSort для порядка, SetLimit и SetSkip для объёма.
- Сортировка принимает bson.D: мультиключевая bson.M отвергается клиентской ошибкой ErrMapForOrderedArgument.
- Включающая проекция отдаёт перечисленные поля плюс _id; исключение _id — единственное смешивание, которое разрешено.
- Непроектированные поля декодируются нулями: проекция меняется вместе со структурой, иначе отчёты видят нули.
- Лимит без сортировки отдаёт произвольный набор документов — пара SetSort плюс SetLimit обязательна.
- Skip растёт линейно по глубине страницы: для глубоких листаний дешевле диапазон по _id.
- CountDocuments даёт число совпадений для пагинации, SetBatchSize режет транспорт, а не результат.
- У FindOneOptions те же SetSort и SetProjection, что у Find; нет только SetLimit — одиночному документу он не нужен.

## Домашнее задание

Соберите страницу каталога: фильтр по категории, сортировка по цене с переключением направления, лимит 20 и skip по номеру страницы, проекция карточки из трёх полей. Затем измерьте разницу: запрос с проекцией и без неё на коллекции с тяжёлым полем-описанием — локальная база и mongosh с explain покажут разницу в пересылаемых байтах. Добавьте в страницу переключение направления сортировки и проверьте, что CountDocuments и Find получают один и тот же собранный фильтр — рассинхрон двух фильтров чаще всего ломает номера страниц.

## Вопросы и ответы по уроку

### Почему драйвер не принимает bson.M в SetSort?

Потому что обход map в Go не упорядочен, а порядок полей сортировки определяет результат для равных значений: драйвер не может выбрать, «price до name» или наоборот. Мультиключевая карта в Sort даёт клиентскую ошибку ErrMapForOrderedArgument — bson.D со срезом пар сохраняет порядок явно.

### Проекция или узкая структура — что выбрать для списков?

Вместе: узкая структура описывает форму выдачи ленты, проекция обещает серверу не отправлять остальное. Одна структура без проекции гоняет полные документы по сети, одна проекция без структуры оставляет bson.M без типов. Для карточки товара — отдельный полный тип.

### Почему на глубокой странице запрос тормозит?

SetSkip(10000) заставляет сервер пройтись и отбросить десять тысяч совпавших документов перед выдачей: цена растёт линейно с глубиной. Для «дальше и дальше» используют диапазон по _id: {_id: {$gt: последний}} читает следующую страницу из индекса без отбрасывания. Если страницы листают вслед за новыми записями — лента событий, журнал — диапазон дешевле с первого же листа.
