---
title: "ObjectID: анатомия идентификатора"
description: "Анатомия ObjectID в mongo-driver v2: 12 байт, bson.ObjectID в Go, Hex и Timestamp, сортировка по времени создания и когда на неё нельзя полагаться."
course: "MongoDB в Go: официальный драйвер mongo-driver v2"
url: "https://golangmaster.ru/courses/mongo-driver/lessons/12-objectid/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# ObjectID: анатомия идентификатора

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

ObjectID — это идентификатор, который mongo-драйвер ставит в _id автоматически, и внутри его 24 hex-символов спрятаны время создания, отпечаток процесса и счётчик. Разберём анатомию из 12 байт, тип bson.ObjectID в Go, сортировку по времени создания — и случаи, когда на неё нельзя полагаться.

В логах и URL он встречается каждый день: 68a1f3c4b2e9d7056124a8f3 — строка из 24 hex-символов, которую поддержка просит прислать, а дизайнер API кладёт в путь /products/{id}. В четвёртом уроке мы оставили InsertOne без _id, и сервер сгенерировал его сам — вернув в InsertedID. Теперь вскроем это значение: ObjectID не случайный хэш, а структурированная конструкция из 12 байт, в которой зашито время создания документа. Из этого следуют практические суперсилы: дату создания можно узнать без обращения к базе, список документов по _id отсортировать «от старых к новым» почти бесплатно, а пагинацию построить на диапазоне идентификаторов. Из этого же следуют и ловушки: точность всего секунда, порядок внутри секунды не совпадает с порядком вставки, а часы разных машин врут относительно друг друга. Урок собран вокруг типа bson.ObjectID из второго мажора драйвера — в v1 он жил в пакете primitive, и половина старых туториалов отсюда не компилируется.

### Анатомия ObjectID: 12 байт вместо 24 символов

Хранится ObjectID как 12 сырых байт, а 24 hex-символа — это лишь текстовое представление: каждый байт кодируется двумя символами. Внутри — три секции. Первые четыре байта — время в секундах от начала эпохи Unix, записанное big-endian: это и есть время создания, с точностью до секунды. Следующие пять байт — уникальный отпечаток процесса: случайное значение, которое драйвер выбирает один раз при старте процесса и дальше ставит во все идентификаторы этого процесса. Последние три байта — счётчик, который инкрементируется атомарно на каждый новый ObjectID; стартует он тоже со случайного значения, чтобы два процесса не начали с одинаковой точки. Складывая секции, получаем свойства идентификатора: он уникален без единого обращения к серверу, монотонно растёт внутри одного процесса и почти упорядочен по времени между машинами. Именно поэтому mongo objectid стал дефолтным _id в mongo: вставка не требует координации, а база выигрывает плотность записей в индексе — новые документы ложатся в правый край B-дерева, а не раскидываются по всему дереву. Big-endian здесь не украшение: старший байт времени идёт первым, поэтому побайтовое сравнение идентификаторов — это и есть сравнение по времени. Счётчик — три байта, больше шестнадцати миллионов значений: атомарный инкремент на каждый вызов, при переполнении отсчёт начинается заново. У секции времени есть и побочный эффект: hex открывает дату создания любому, кто видит URL, — для номеров заказов это иногда нежелательно, и ниже разберём альтернативы.

### Hex, Timestamp и обратная дорога

```go
func inspect(id bson.ObjectID) {
        // время создания зашито в первых четырёх байтах
        fmt.Println("hex:", id.Hex())
        fmt.Println("создан:", id.Timestamp().Format(time.RFC3339))

        fromURL, err := bson.ObjectIDFromHex("68a1f3c4b2e9d7056124a8f3")
        if err != nil {
                log.Fatal(err) // the provided hex string is not a valid ObjectID
        }
        fmt.Println(fromURL) // ObjectID("68a1f3c4b2e9d7056124a8f3")
}
```

Hex возвращает 24 hex-символа — то, что видно в URL и mongosh; Timestamp достаёт время создания без обращения к базе, в UTC. ObjectIDFromHex — обратная дорога из строкового параметра: строка обязана быть ровно 24 символа, иначе ErrInvalidHex. Print печатает через String() — формат ObjectID("...") из исходников драйвера.

### bson.ObjectID в Go: типы и методы

В v2 драйвера тип живёт в пакете bson: bson.ObjectID — это массив [12]byte со скромным набором методов, и все они целиком клиентские: ни один не обращается к серверу. NewObjectID() генерирует идентификатор от текущего времени — тот же код, что стоит за автогенерацией _id при вставке. NewObjectIDFromTimestamp(t) собирает ObjectID от произвольной метки — удобно для «синтетических границ» при запросах по диапазону времени, об этом ниже. IsZero() проверяет нулевое значение: пустой ObjectID — это NilObjectID, и отличить «не задан» от «получен из базы» помогает именно он. ObjectIDFromHex и Hex — пара для текстового представления: параметр запроса входит строкой, а уезжает в фильтр уже байтами. Отдельно про сериализацию: ObjectID реализует encoding.TextMarshaler, поэтому в JSON-ответах API автоматически превращается в hex-строку, а не в массив байт — одним пакетом encoding/json решён вопрос «как отдавать _id наружу». Обратное направление продумано так же: UnmarshalText принимает и 24 hex-символа, и 12 сырых байт, а пустую строку декодирует в NilObjectID — необязательный параметр API не ломает разбор. Метка времени в NewObjectIDFromTimestamp задаёт только первые четыре байта: отпечаток процесса и счётчик берутся текущие, поэтому два идентификатора от одной метки различаются. Имена конструкторов переезжать не заставляют: NewObjectID и ObjectIDFromHex называются одинаково в обеих версиях — меняется только пакет. Старый код с primitive.ObjectID переносится заменой импорта: поля, конструкторы и методы совпадают один в один.

### Методы bson.ObjectID

| Метод | Что возвращает | Заметка |
| --- | --- | --- |
| Hex() | 24 hex-символа строкой | формат для URL, логов и mongosh |
| Timestamp() | time.Time из первых 4 байт | всегда UTC, точность — секунды |
| IsZero() | true для NilObjectID | проверка «идентификатор не задан» |
| ObjectIDFromHex(s) | ObjectID и ошибку ErrInvalidHex | строка обязана быть ровно 24 символа |
| NewObjectID() | новый ObjectID от time.Now() | тот же код, что за автогенерацией _id |
| String() | ObjectID("hex") | удобен в логах и сообщениях об ошибках |

### Сортировка по _id: что гарантировано

Поскольку время стоит в первых байтах, сортировка по _id — это почти сортировка по времени создания: SetSort(bson.D{{Key: "_id", Value: 1}}) выстроит документы от старых к новым без отдельного поля created_at и без индекса поверх него — _id проиндексирован с рождения. На этом держится и пагинация по диапазону: «документы после этого курсора» записывается как {_id: {$gt: последний}}, и следующий лист читается без дорогого skip, о котором подробно поговорим в тринадцатом уроке. Границы диапазона удобно строить из времени: NewObjectIDFromTimestamp(полночь) даёт идентификатор, меньше которого ничего из этого дня нет, — условие {_id: {$gte: утро}, $lt: следующий день} читает документы дня прямо из индекса. Слово «почти» здесь ключевое, и на нём стоит задержаться. Точность времени — секунда: документы одной секунды различаются уже счётчиком, который растёт внутри одного процесса. Значит, для документов одного процесса порядок внутри секунды стабилен, а вот между разными процессами и машинами гарантий нет. Для выдач пользователю, лент и отчетов этого хватает; для строгой хронологии — нет, и следующий коллаут объясняет почему.

> **Живое место: сортировка по _id как по времени.** Команда полагалась на порядок _id в финансовом журнале — и получила рассинхрон после добавления второго инстанса приложения: часы машин разъезжались на секунды, NTP один раз шагнул назад, и запись «позже» встала «раньше». Правило: _id — эвристика для списков, а не аудит. Для строгой хронологии храните отдельное created_at с серверным временем и сортируйте по нему. Проверка дешёвая: сравните порядок _id с порядком created_at на десятке записей из двух процессов — расхождение внутри одной секунды видно сразу.

### Фильтр по _id из URL

Самый частый запрос в любом сервисе — «найди по идентификатору из пути»: /products/68a1f3c4... Параметр приходит строкой, а в фильтр должен уехать bson.ObjectID, поэтому конвертация ставится до запроса: id, err := bson.ObjectIDFromHex(raw). Ошибка конвертации — это 400 от вашего хендлера, а не поход на сервер: строка короче 24 символов, с не-hex символами или пустая отсекается мгновенно. Пропустить проверку и подставить сырую строку в фильтр — значит отправить заведомо пустой запрос: сервер молча вернёт отсутствие совпадений, и вместо честной ошибки «неверный формат» пользователь получит загадочное «не найдено». Дальше всё знакомо: bson.M{"_id": id} — самый дешёвый фильтр в базе, значение лежит в уникальном индексе; FindOne с Decode и ErrNoDocuments работают по правилам шестого урока. Обратный путь — id.Hex() в ответе API или в логе — замыкает круг строка-байты-строка, и никакого второго представления изобретать не нужно. В коллекции при этом лежат именно байты: кодек пишет BSON-тип ObjectID длиной двенадцать, а не строку, — вторая копия hex в документе не заводится, и индекс остаётся компактным. Ошибка ErrNoDocuments здесь — нормальный путь «не найдено»: отдавать её наружу 404-й страницей — решение хендлера, а не драйвера, драйвер честно сообщает о пустоте.

### Хендлер «найди по идентификатору»

```go
func GetProduct(ctx context.Context, coll *mongo.Collection, hexID string) (Product, error) {
        id, err := bson.ObjectIDFromHex(hexID)
        if err != nil {
                return Product{}, ErrBadID // строка из URL не похожа на ObjectID
        }

        var p Product
        err = coll.FindOne(ctx, bson.M{"_id": id}).Decode(&p)
        if errors.Is(err, mongo.ErrNoDocuments) {
                return Product{}, ErrNotFound
        }
        return p, err
}
```

Типовой хендлер: строку из URL конвертируем в bson.ObjectID до запроса — невалидная строка отсекается с 400, а не уезжает на сервер. Фильтр по _id — самый дешёвый поиск: значение лежит в уникальном индексе. Дальше всё как в шестом уроке: Decode в структуру и ErrNoDocuments на пустоте через errors.Is.

### Что стоит запомнить про ObjectID

- 12 байт: 4 — секунды времени создания, 5 — отпечаток процесса, 3 — счётчик.
- Hex-строка из 24 символов — только представление; хранится и фильтруется байтами.
- Timestamp() даёт время создания без запроса к базе — всегда UTC, с точностью до секунды.
- Сортировка по _id — эвристика «от старых к новым»; строгая хронология — отдельное поле created_at.
- Пагинация по диапазону {_id: {$gt: ...}} дешевле skip на глубоких страницах.
- Строку из URL конвертируйте ObjectIDFromHex до запроса — ошибку формата отдавайте как 400.

### Проверка: структура идентификатора

**Вопрос:** Из каких секций состоят 12 байт ObjectID?

- ✅ 4 байта времени, 5 байт отпечатка процесса, 3 байта счётчика
- 8 байт времени и 4 байта счётчика
- 4 байта случайных, 8 байт хэш имени коллекции
- 12 случайных байт без структуры

Секция времени делает идентификатор почти упорядоченным, отпечаток процесса разводит разные машины, а счётчик разводит документы одного процесса внутри секунды. Ничего случайного при генерации, кроме стартовых значений, нет — это и даёт предсказуемые свойства.

### Проверка: Timestamp без базы

**Вопрос:** Что вернёт id.Timestamp() для ObjectID, полученного из базы?

- Ошибку: время хранится только в отдельном поле
- nil — время генерируется только при вставке в Go-коде
- ✅ Время создания документа по первым байтам, в UTC
- Локальное время сервера в момент запроса

Время — часть самого идентификатора, поэтому Timestamp() вычисляет его из первых четырёх байт локально, без сети. Возвращаемая зона — UTC; для отображения пользователю приводите к нужной зоне самостоятельно.

### Что напечатает Hex() у нуля?

```go
var id bson.ObjectID // нулевое значение

fmt.Println(len(id.Hex()), id.IsZero())
```

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

- ✅ 24 true
- 24 false
- 0 true
- 12 false

Нулевой ObjectID — NilObjectID: IsZero() отвечает true, а Hex() возвращает ровно 24 символа из нулей. Отсюда практическая привычка: проверяйте IsZero() у структур, где _id заполняется не всегда, — с omitempty сервер сгенерирует идентификатор сам, а без omitempty в коллекцию уедет нулевой, и вторая такая вставка упадёт с duplicate key.

### Когда ObjectID — не лучший выбор

Дефолтный _id хорош всем, кроме особых случаев, и их полезно распознавать заранее. Естественные ключи — артикул, ISBN, slug товара — сами уникальны, и двойной идентификатор только путает: такие коллекции живут с _id-строкой. Распределённым системам, где идентификаторы генерируют десятки сервисов и время машин разъезжается, чаще подходит UUID: 16 байт без намёка на время, зато без утечки «когда создан» наружу — для номеров заказов это иногда требование безопасности. Последовательные номера из бизнес-логики — «номер заказа 1042» — собираются через FindOneAndUpdate с $inc, как в десятом уроке, и живут рядом с ObjectID, а не вместо него. Наконец, URL-дизайн: hex из 24 символов длиннее человеческого slug, поэтому каталоги часто имеют и внутренний _id, и публичный slug с уникальным индексом. Правило простое: ObjectID — разумный дефолт, пока не появился внешний ключ, требование скрыть время или последовательность для людей; тогда выбор осознанный, а не привычный. Для случая UUID готового типа в пакете bson нет — MongoDB хранит UUID бинарным сабтипом, драйвер кодирует []byte, и это ещё один аргумент не заводить UUID без необходимости. С slug-подходом связана и привычная пара индексов: уникальный на slug для публичных страниц и отдельный на created_at для сортировок, которые не хочется привязывать к идентификатору.

> **v1 → v2: primitive.ObjectID стал bson.ObjectID.** В первой мажорной версии идентификатор жил в пакете primitive: primitive.ObjectID, primitive.NewObjectID(), primitive.ObjectIDFromHex(). В v2 пакет влит в bson: bson.ObjectID, bson.NewObjectID(), bson.NilObjectID. Код из старых туториалов лечится заменой импорта — типы и методы те же. Пятый урок уже упоминал переезд; здесь он важен буквально каждый раз, когда копируете хелпер для _id из статьи трёхлетней давности. Быстрая проверка кодовой базы: поиск по «primitive.» — каждое вхождение, кроме служебных комментариев, кандидат на замену импорта.

### Соберите поиск по идентификатору

Восстановите конвертацию из строки, фильтр и чтение времени создания.

```go
id, err := bson.___(hexID)
if err != nil {
        return err
}
var doc Product
err = coll.FindOne(ctx, bson.M{"___": id}).Decode(&doc)
fmt.Println("создан:", doc.ID.___().Format(time.RFC3339))
```

Подсказка: Конвертация строки — ObjectIDFromHex; в фильтре поле _id; время создания достаёт метод Timestamp().

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

### Соберите функцию разбора идентификатора

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

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

1. `func ParseID(raw string) (bson.ObjectID, error) {`
2. `  if len(raw) != 24 {`
3. `    return bson.NilObjectID, ErrBadID`
4. `  }`
5. `  id, err := bson.ObjectIDFromHex(raw)`
6. `  if err != nil { return bson.NilObjectID, err }`
7. `  return id, nil`
8. `}`

### Карточки: ObjectID

- **ObjectID** — 12 байт: 4 байта времени создания в секундах, 5 байт отпечатка процесса, 3 байта счётчика. Hex-представление — 24 символа.
- **bson.ObjectID** — Тип v2 из пакета bson (в v1 — primitive.ObjectID). Массив [12]byte с Hex, Timestamp, IsZero и конструкторами FromHex и NewObjectID.
- **Timestamp()** — Время создания из первых байт, без обращения к базе. Всегда UTC, точность — секунда. Для строгой хронологии нужно поле created_at.
- **ObjectIDFromHex** — Строка в байты: ровно 24 hex-символа, иначе ErrInvalidHex. Вызов ставится до запроса — ошибка формата это 400, а не «не найдено».
- **NilObjectID** — Нулевое значение типа. IsZero() отличает «не задан» от реального идентификатора — проверяйте перед вставкой.
- **Пагинация по _id** — Условие {_id: {$gt: last}} заменяет дорогой skip: индекс по _id бесплатен, порядок — «от старых к новым» в пределах секундной точности.

Практика без базы тоже доступна: bson.NewObjectID(), Hex(), Timestamp() и ObjectIDFromHex — чистый Go, и структура идентификатора видна сразу в выводе. Дальше идентификатор работает по всему курсу: в уроке про [InsertOne](/courses/mongo-driver/lessons/04-insert-dokumentov/) он появлялся в InsertedID, фильтр по _id — базовый пример [урока про фильтры](/courses/mongo-driver/lessons/08-filtry/), а пагинация по диапазону _id встретится с дорогим skip в уроке про [проекции, сортировку и лимиты](/courses/mongo-driver/lessons/13-proekcii-sort-limit/). Для счётчиков-номеров, которые заметил раздел про «не лучший выбор», пригодится [upsert с FindOneAndUpdate](/courses/mongo-driver/lessons/10-upsert/). Песочница сайта показывает тематическую программу на чистом Go, поэтому эксперименты с байтами идентификатора гоняйте локально вместе с mongosh — вторая команда из второго урока поднимет всё нужное.

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

- ObjectID — 12 байт: 4 секунды времени, 5 отпечаток процесса, 3 счётчик; hex-строка — лишь представление.
- В v2 тип называется bson.ObjectID; primitive.ObjectID — маркер кода первой версии.
- Timestamp() достаёт время создания без базы: всегда UTC, точность секунды.
- Сортировка по _id — эвристика «от старых к новым»: внутри одного процесса стабильна, между машинами — нет.
- Строгая хронология живёт в отдельном created_at, а не в идентификаторе.
- Пагинация по {_id: {$gt: last}} дешевле skip на глубоких страницах.
- Строку из URL конвертируйте ObjectIDFromHex до запроса: ошибка формата — 400, а не молчаливое «не найдено».
- MarshalJSON и TextMarshaler отдают hex-строку: JSON-ответы API работают без ручных конвертаций.

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

Соберите утилиту разбора идентификаторов: функция из строки в bson.ObjectID с проверкой длины и ошибкой формата, печать времени создания через Timestamp и разница во времени между двумя ObjectID из ваших данных. Затем подключите её к хендлеру «найди по идентификатору» локально и сравните поведение на валидной строке, невалидной и пустой — лог должен различать 400 и 404. Завершите сборкой границ дня через NewObjectIDFromTimestamp: диапазон по _id от полуночи до следующей полуночи должен находить ровно документы нужного дня.

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

### Можно ли генерировать ObjectID на клиенте, а не на сервере?

Да, и драйвер делает это сам: bson.NewObjectID() в поле _id с omitempty заполняется до отправки, и сервер ничего не генерирует. Это удобно: идентификатор известен сразу после сборки структуры, до ответа базы. В четвёртом уроке мы видели InsertedID для серверной генерации — с клиентской он просто не нужен.

### Насколько надёжна сортировка по _id вместо created_at?

Для списков и лент — надёжна: документы почти всегда выходят «от старых к новым». Для аудита, финансов и споров о порядке — ненадёжна: секунда точности, разные часы машин, шаги NTP. Правило: _id — бесплатная эвристика, created_at — обязательство, и их не подменяют друг другом. Полезно держать и обратный тест: если сортировка по _id когда-нибудь расходится с created_at, разбор начинается с часов и синхронизации времени, а не с драйвера.

### Почему Hex даёт 24 символа, а не 12?

Hex-кодировка пишет каждый байт двумя символами: 12 байт превращаются в 24 hex-символа. Обратно работает ObjectIDFromHex: он требует ровно 24 символа и возвращает 12 байт. Поэтому длина строки — первая проверка валидности идентификатора из URL.
