MongoDB CRUD Operations: Create, Read, Update, Delete in mongosh and DbSchema
For the developer who has a MongoDB database and now needs to write documents into it and read them back; every method is shown in mongosh with the result it produces.
On this page
A collection has to take new documents, hand them back, change a field on some of them, and drop the ones that no longer belong. mongosh covers all four with eight methods, paired so that one of each pair works on a single document and the other on many. A ninth, replaceOne, swaps a whole document for another one.
| Operation | One document | Several documents |
|---|---|---|
| Create | insertOne() | insertMany() |
| Read | findOne() | find() |
| Update | updateOne() | updateMany() |
| Delete | deleteOne() | deleteMany() |
What insertOne and insertMany write to a collection
db.passengers.insertOne({ name: "Jennifer", age: 21, seat: 34 })
The insertOne call above runs in mongosh against the flights database built in the previous lesson. The passengers collection does not have to exist beforehand, because MongoDB creates a collection the first time a document is inserted into it. The document arrives with no _id, so MongoDB adds one of type ObjectId and returns it as insertedId.
insertMany takes an array and writes the documents in one call:
db.passengers.insertMany([
{ name: "Michael", age: 30, seat: 12 },
{ name: "Sarah", age: 25, seat: 18 },
{ name: "David", age: 40, seat: 22 },
{ name: "Emily", age: 28, seat: 7 },
{ name: "James", age: 35, seat: 15 }
])
Both methods write into one collection. There is no insert that spans two collections, so a document that belongs in two places is written twice, once per collection.
The array is inserted in order, and the manual says that when ordered is left at its default of true and one insert fails, the server stops there, leaving the documents after the failure unwritten. Pass { ordered: false } as the second argument and the remaining documents are inserted anyway, which is what you want for an import where one bad record should not hold back the rest.
Reading documents back with find and findOne
db.passengers.find()
| name | age | seat |
|---|---|---|
| Jennifer | 21 | 34 |
| Michael | 30 | 12 |
| Sarah | 25 | 18 |
| David | 40 | 22 |
| Emily | 28 | 7 |
| James | 35 | 15 |
Every document also carries the _id MongoDB generated for it, which the tables in this article leave out and mongosh prints in full:

find() with no argument returns every document in the collection. What comes back is a cursor rather than an array, which is why mongosh prints a batch and waits before printing more. findOne() returns a single document instead, the first one the collection yields:
db.passengers.findOne()
| name | age | seat |
|---|---|---|
| Jennifer | 21 | 34 |
The filter document that narrows a find
db.passengers.find({ age: 30 })
| name | age | seat |
|---|---|---|
| Michael | 30 | 12 |
A filter is a document whose fields are the conditions a stored document has to meet. Given to find() it narrows the result set; given to findOne() it decides which single document comes back. A filter that matches nothing is not an error: find() returns an empty cursor and findOne() returns null.
Which fields a projection returns
db.passengers.find({}, { _id: 0, name: 1, seat: 1 })
| name | seat |
|---|---|
| Jennifer | 34 |
| Michael | 12 |
| Sarah | 18 |
| David | 22 |
| Emily | 7 |
| James | 15 |
The second argument of find() is the projection:
1includes a field,0excludes it._idcomes back unless you exclude it by name. Drop the_id: 0from the projection above and each row carries its ObjectId as well.- Apart from
_id, a projection either lists fields to include or lists fields to exclude, never both in the same document.
Sorting and limiting the result
db.passengers.find({ age: { $gte: 25 } })
.sort({ age: -1, name: 1 })
.limit(3)
| name | age | seat |
|---|---|---|
| David | 40 | 22 |
| James | 35 | 15 |
| Michael | 30 | 12 |
$gte keeps the passengers aged 25 and over, which leaves Jennifer out of the cursor. sort() orders what is left by age from the highest down, with the name breaking a tie, and limit() cuts the cursor after three documents. The order of the two calls in the chain does not matter: the manual says a sort() used together with a limit() returns the first documents in the sort order, so the three you get are the top three of the whole result and not three arbitrary ones.
How updateOne, updateMany and replaceOne differ
db.passengers.updateOne(
{ name: "Jennifer" },
{ $set: { destination: "Paris" } }
)
{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }
The first argument is the filter that picks the document, the second is the update document, and its top level holds update operators rather than plain field values. $set writes a field and creates it where the document has none, so Jennifer's document gains a destination it never had:
| name | age | seat | destination |
|---|---|---|---|
| Jennifer | 21 | 34 | Paris |

updateMany takes the same two arguments and writes to every document the filter matches:
db.passengers.updateMany(
{ seat: 34 },
{ $set: { seat: 40 } }
)
{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }
One passenger sat in seat 34, so one document matched and one changed. Run the same call with a filter that matches four documents and all four are rewritten, where updateOne would have stopped at the first.
replaceOne takes a document instead of operators, and that document becomes the stored one:
db.passengers.replaceOne(
{ name: "Jennifer" },
{ name: "Jennifer", seat: 45, destination: "New York" }
)
{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }
| name | seat | destination |
|---|---|---|
| Jennifer | 45 | New York |
The _id survives the replacement, because the MongoDB manual says the value of _id is immutable, so no write can change it. Everything else comes from the replacement, so the age field that the replacement leaves out is gone from the document afterwards. That is the difference to hold on to: updateOne edits the fields you name, replaceOne decides the whole document.
Upserts and bulk writes
Pass upsert: true as a third argument and an update that matches nothing inserts instead:
db.passengers.updateOne(
{ name: "Chris" },
{ $set: { seat: 14, destination: "Rome" } },
{ upsert: true }
)
| name | seat | destination |
|---|---|---|
| Chris | 14 | Rome |
No document matched, so matchedCount and modifiedCount came back as 0, upsertedCount as 1, and upsertedId carries the _id of the document MongoDB created out of the filter and the update. An upsert is how a job that runs every hour writes a record without a lookup first.
bulkWrite sends several writes of different kinds in one request:
db.passengers.bulkWrite([
{
updateOne: {
filter: { name: "Michael" },
update: { $set: { destination: "Berlin" } }
}
},
{
deleteOne: {
filter: { name: "James" }
}
}
])
One round trip carries the update and the delete, and the reply counts each kind of operation separately: one document matched and modified, one document deleted. That is what makes bulkWrite worth the longer syntax when an application has a batch of writes to apply.
Removing documents with deleteOne and deleteMany
db.passengers.deleteOne({ name: "Jennifer" })
{ acknowledged: true, deletedCount: 1 }
deleteOne removes the first document the filter matches and stops there, so a filter that matches three documents still deletes one. Which of the three goes is not something the call decides, and nothing in the query says it, so a delete meant for one particular document is written against _id or against a field with a unique index on it. deleteMany removes every match instead:
db.passengers.deleteMany({ age: { $gte: 30 } })
{ acknowledged: true, deletedCount: 2 }
Michael and David were the two passengers aged 30 and over still in the collection, James having gone in the bulk write and Jennifer in the delete above. Chris survives the call because the upsert gave him no age field at all, and a document without the field cannot satisfy a condition on it. Sarah, Emily and Chris are what is left.
An empty filter matches every document, so deleteMany({}) empties the collection.
The same CRUD operations in DbSchema
DbSchema connects to MongoDB and introspects a configurable sample of documents per collection, deriving the field names, the BSON types, the nested objects and the arrays from what the sample holds, so the diagram approximates what the sampled documents contain and is never a structure MongoDB enforces. Where a collection carries a validation rule, DbSchema reverse-engineers that rule instead and treats it as the collection's authoritative structure.

Right-click the collection header in the diagram and choose Open in Relational Data Editor, and the documents appear as a grid. Each CRUD operation has a control there:
- Insert opens an edit form for a new document.
- Edit, or a double-click on a cell, changes a value in place.
- Delete removes the selected row.
- Clicking a column header opens the filter dialog for that column, the grid's equivalent of the filter document above.
Insert, Edit and Delete reach MongoDB only when you click Commit, and Rollback discards them instead.

Changing the structure of a collection is a separate action from changing its data, and the two write to different places. Double-click a collection header in the diagram to edit its validation rule, or create a collection there, and DbSchema writes the rule to the live database and to the model file together. Dragging one collection's field onto another's creates a virtual relation, a link MongoDB neither declares nor enforces: DbSchema draws it as a connector line and saves it in the model file alone, leaving the database untouched.
Those virtual relations are what makes the grid cascade. Selecting a passenger refilters every child pane to the documents whose field values match, as many levels deep as the relations go, and exporting the model as interactive HTML5 documentation carries the diagram as a vector image with the collection and field comments readable as mouse-over tooltips. A schema inferred from the documents, the validation rule where one exists, the virtual relations, the browsing across them, and the HTML5 documentation are one chain. Other tools cover parts of it.
The statements from the sections above run inside DbSchema as they are: its Query Editor takes native MongoDB syntax, so db.passengers.find({ age: 30 }) returns the same document there without leaving the diagram.
Download DbSchema at https://dbschema.com/download.html, connect to your MongoDB database, and run the inserts above in the Query Editor before opening the collection as a grid. Connecting, reverse-engineering and the Query Editor are in the free Community Edition; the Relational Data Editor, saving the model to a file and the HTML5 documentation are Pro.
FAQ
What is the difference between find() and findOne()?
find() returns a cursor over every matching document, which mongosh prints one batch at a time. findOne() returns a single document, the first match, or null when the filter matches nothing.
Can MongoDB CRUD operations be done without the shell?
DbSchema's Query Editor takes native MongoDB syntax, and putting the database name in front of the collection, as in sampledb.employees.find(), sends a statement to a database other than the connected one. Editing documents without writing a statement at all is the grid described in the section above.
What should I read after MongoDB CRUD operations?
The next topics that build on these methods are MongoDB aggregation pipelines, $lookup joins and MongoDB indexes, which decide how fast a filter like the ones above runs.
The next lesson covers embedded documents and arrays in MongoDB.