MongoDB CRUD: вставка, выборка, обновление и удаление
✓ Все примеры выполнены на MongoDB 8.2.6, драйвер mongodb 7.5.0, Node.js v22.23.1 — июль 2026
Содержание
CRUD в MongoDB — это четыре группы методов коллекции. Синтаксис простой, и именно поэтому большинство проблем возникает не в нём, а в деталях: что вернула операция, какого типа _id и почему запрос перебирает всю коллекцию.
Весь код ниже выполнен на живом сервере MongoDB 8.2.6 — вывод настоящий.
Вставка: insertOne и insertMany
import { MongoClient } from 'mongodb';
const client = new MongoClient(uri);
await client.connect();
const users = client.db('shop').collection('users');
const r1 = await users.insertOne({ name: 'Аня', age: 30, city: 'Москва' });
console.log('acknowledged :', r1.acknowledged);
console.log('insertedId :', r1.insertedId, '| тип:', r1.insertedId.constructor.name);
acknowledged : true
insertedId : new ObjectId('6a59e5dd7368400e383caa62') | тип: ObjectId
Базу и коллекцию заранее создавать не нужно — они появятся при первой записи.
Неочевидная деталь: _id генерирует драйвер на вашей машине, а не сервер. Это позволяет знать id ещё до похода в базу. Задать его можно и самому — тогда он не обязан быть ObjectId:
await users.insertOne({ _id: 'user-42', name: 'Борис', age: 25 });
Но _id уникален, и повтор упадёт:
ошибка : MongoServerError | code: 11000
сообщение : E11000 duplicate key error collection: shop.users index: _id
Код 11000 — тот самый «duplicate key». Ловить его нужно по err.code, а не по тексту сообщения.
Пачкой:
const r3 = await users.insertMany([
{ name: 'Вера', age: 28, city: 'Казань' },
{ name: 'Глеб', age: 35, city: 'Москва' },
{ name: 'Дина', age: 41, city: 'Пермь' },
]);
console.log('вставлено:', r3.insertedCount);
вставлено: 3
Схемы нет — и это не фигура речи
В соседних документах одной коллекции могут быть разные поля. Проверим:
await users.insertOne({ name: 'Егор', любимыйЦвет: 'синий', теги: ['a', 'b'] });
наборы полей у документов:
name, age, city
name, age
name, age, city
name, age, city
name, age, city
name, любимыйЦвет, теги
Никто не возразил. Это одновременно и главное удобство MongoDB, и её главная опасность: опечатка в имени поля не ошибка, а новое поле. Написали usre вместо user — документ запишется, а запрос по user его не найдёт.
Отсюда и берётся Mongoose: он добавляет схему и валидацию поверх драйвера. Либо то же самое делает сама база — через $jsonSchema в валидаторе коллекции.
Выборка: find и findOne
await users.find({ city: 'Москва' }).toArray();
await users.find({ age: { $gt: 30 } }).toArray();
await users.find({ city: 'Москва', age: { $gt: 29 } }).toArray();
await users.find({ $or: [{ city: 'Москва' }, { city: 'Пермь' }] }).toArray();
все из Москвы : [ 'Аня', 'Глеб' ]
старше 30 : [ 'Глеб', 'Дина' ]
Москва И >29 : [ 'Аня', 'Глеб' ]
Москва ИЛИ Пермь: [ 'Аня', 'Глеб', 'Дина' ]
Логика фильтров:
- несколько полей в объекте — это И. Отдельного
$andдля этого не нужно; $orпишется явно и принимает массив условий;- сравнения — операторами:
$gt,$gte,$lt,$lte,$ne,$in,$nin.
Важно про find: он возвращает курсор, а не массив. Данные не поедут из базы, пока вы не вызовете toArray() или не пройдёте курсор циклом. На больших выборках toArray() — это то же «прочитать всё в память», только из базы; там нужен for await.
findOne возвращает документ либо null — не пустой массив и не исключение:
найден : Аня
не найден: null
Проекция, сортировка и лимит задаются рядом:
await users.find({}, { projection: { _id: 0, name: 1, age: 1 } })
.sort({ age: -1 })
.limit(3)
.toArray();
топ-3 по возрасту: [{"name":"Дина","age":41},{"name":"Глеб","age":35},{"name":"Аня","age":30}]
Проекция — не косметика: она сокращает объём, который база читает и гонит по сети. _id возвращается всегда, если явно не отключить его через _id: 0.
Обновление: где живут ошибки
await users.updateOne({ name: 'Аня' }, { $set: { age: 31 } });
после $set: { name: 'Аня', age: 31, city: 'Москва' }
$set обязателен. Он меняет указанные поля и не трогает остальные. Без оператора обновления современный драйвер выбросит ошибку — и это хорошо: в старых версиях такой вызов молча заменял документ целиком, стирая все поля, которых не было в объекте. Классический способ потерять данные.
Теперь главное — что возвращает операция:
const up = await users.updateMany({ city: 'Москва' }, { $set: { скидка: true } });
console.log('matchedCount:', up.matchedCount, '| modifiedCount:', up.modifiedCount);
// повторяем тот же запрос
const up2 = await users.updateMany({ city: 'Москва' }, { $set: { скидка: true } });
первый раз : matchedCount: 2 | modifiedCount: 2
второй раз : matchedCount: 2 | modifiedCount: 0 <- данные те же, записи не было
Это ловушка, на которой ломается логика. matchedCount — сколько документов подошло под фильтр. modifiedCount — сколько реально изменилось. Если новые значения совпали со старыми, база не пишет ничего, и modifiedCount будет нулём.
Проверка вида if (result.modifiedCount === 0) throw new Error('не найдено') — баг: документ есть, просто данные те же. Для факта существования смотрите matchedCount.
upsert: обновить или создать
const up3 = await users.updateOne(
{ name: 'Жанна' },
{ $set: { age: 22, city: 'Сочи' } },
{ upsert: true }
);
matchedCount: 0 | upsertedId: new ObjectId('6a59e5dd821c225c4e52a896')
Ничего не нашлось — документ создан, и его id пришёл в upsertedId. Одна операция вместо «проверить и вставить», а заодно без гонки между проверкой и вставкой.
Операторы, которые нужны чаще всего
await users.updateOne({ name: 'Аня' }, { $inc: { age: 1 }, $push: { теги: 'vip' } });
await users.updateOne({ name: 'Аня' }, { $unset: { скидка: '' } });
после $inc/$push: { age: 32, 'теги': [ 'vip' ] }
после $unset : _id, name, age, city, теги
| Оператор | Что делает |
|---|---|
$set |
задать значение поля |
$unset |
удалить поле целиком |
$inc |
увеличить число (можно на отрицательное) |
$push / $pull |
добавить / убрать элемент массива |
$addToSet |
добавить в массив, если такого ещё нет |
$inc важен не удобством, а атомарностью: он выполняется на сервере. Прочитать значение в Node, прибавить единицу и записать обратно — это гонка, при которой параллельные запросы потеряют часть инкрементов.
Удаление
await users.deleteOne({ name: 'Егор' });
await users.deleteMany({ city: 'Москва' });
await users.deleteOne({ name: 'Призрак' }); // такого нет
deleteOne : 1
deleteMany : 2
deletedCount : 0 <- ноль, но исключения нет
Два правила:
- удаление несуществующего — не ошибка. Хотите знать факт — смотрите
deletedCount; - пустой фильтр удалит всё.
deleteMany({})очистит коллекцию без единого вопроса.
ObjectId — не строка
Ошибка номер один при работе из веб-приложения. Из HTTP id приходит строкой, а в базе он ObjectId, и MongoDB сравнивает типы строго:
const doc = await users.findOne({ name: 'Вера' });
console.log('поиск по строке :', await users.findOne({ _id: doc._id.toString() }));
console.log('поиск по ObjectId :', (await users.findOne({ _id: new ObjectId(doc._id) }))?.name);
поиск по строке : null
поиск по ObjectId : Вера
null вместо документа. Ни ошибки, ни предупреждения — просто «не найдено», и вы полдня ищете, где потеряли запись. Правило: id из запроса всегда оборачивать в new ObjectId(id). И оборачивать в try/catch — на невалидной строке конструктор бросает исключение.
Бонус: в ObjectId зашито время создания документа.
console.log(doc._id.getTimestamp().toISOString().slice(0, 10));
в ObjectId зашита дата создания: 2026-07-17
Отдельное поле createdAt для многих задач попросту не нужно.
Индекс: 20 000 документов против одного
Самая дорогая ошибка в MongoDB — не синтаксическая. Посмотрим, что делает база без индекса, на коллекции из 20 000 документов:
const plan = await big.find({ num: 19999 }).explain('executionStats');
console.log('этап =', plan.executionStats.executionStages.stage,
'| просмотрено =', plan.executionStats.totalDocsExamined);
БЕЗ индекса: этап = COLLSCAN | просмотрено документов = 20000 | мс = 7
С индексом : этап = FETCH | просмотрено документов = 1 | мс = 0
Данные таблицей
| Показатель | Просмотрено документов |
|---|---|
| Без индекса (COLLSCAN) | 20000 док. |
| С индексом (FETCH) | 1 док. |
Источник: собственный замер: MongoDB 8.2.6, драйвер 7.5.0
COLLSCAN в плане означает, что база перебрала коллекцию целиком. На 20 000 документов это 7 мс, и на разработке вы не заметите. На миллионе — заметят все.
Индекс создаётся одной строкой:
await big.createIndex({ num: 1 });
explain('executionStats') — главный инструмент диагностики MongoDB. Он не гадает, а показывает, что база сделала на самом деле. Если в плане COLLSCAN — запросу нужен индекс.
Что было на этой странице в 2017 году
Первая версия вышла 13 августа 2017 года. Схемы операций из той статьи мы сохранили — они по-прежнему верны, потому что сама модель CRUD в MongoDB не изменилась.
Изменилось окружение. Тогда была MongoDB 3.x, сегодня — 8.2. Драйвер подключался через require, а операции писались колбэками: промис-версии в драйвере только появлялись, и типичный код выглядел как вложенные function(err, result). Сегодня драйвер промисный целиком, и весь код в этой статье — обычный async/await.
Ещё одна деталь эпохи: тогда в ходу были insert(), update() и remove() — без суффиксов One и Many. Их не просто переименовали: старые методы были неоднозначными. update() по умолчанию менял только первый подходящий документ, и об этом постоянно забывали. Явные updateOne и updateMany убрали целый класс ошибок «обновилась одна запись вместо всех».
Смежные темы: что такое MongoDB — зачем она нужна и когда не нужна; вставка документов подробно; промисы — почему драйвер асинхронный. Полный список — в справочнике по Node.js.
Частые вопросы
Почему поиск по `_id` строкой ничего не находит?
_id — это ObjectId, а не строка, и типы в MongoDB сравниваются строго. Оборачивайте значение: new ObjectId(id). Это ошибка номер один при передаче id из HTTP-запроса.Чем `updateOne` без `$set` отличается от `updateOne` с `$set`?
$set меняются указанные поля. Без оператора современный драйвер выбросит ошибку, а в старом коде такой вызов молча заменял документ целиком, стирая все остальные поля.`modifiedCount: 0`, хотя ошибки нет. Что не так?
matchedCount, а не modifiedCount.Нужен ли Mongoose?
Удаление несуществующего документа — это ошибка?
deleteOne вернёт deletedCount: 0 и не бросит исключение. Проверять факт удаления нужно по этому счётчику.