If you reach for Mongoose (or the raw mongodb Node driver) in your TypeScript projects, the official Rust mongodb crate will feel familiar: an async client, typed collections, and BSON documents. The big upgrade is that a Rust collection is generic over your document type, so serialization between Rust structs and BSON is handled by serde — checked at compile time, deserialized once at the boundary.
MongoDB’s official Rust driver (mongodb) is async and integrates with serde: you parameterize a Collection<T> with your own struct, and the driver serializes inserts and deserializes query results through serde automatically. Documents are represented as BSON (MongoDB’s binary JSON), which you build with the ergonomic doc! macro. For a TypeScript developer the surprises are pleasant — there is no separate schema layer like a Mongoose model; your #[derive(Serialize, Deserialize)] struct is the schema — and one sharp edge: a document whose stored shape disagrees with your struct fails at deserialization with a real error, instead of silently handing you undefined.
Note: This file covers the document database side of Section 17: BSON, the doc! macro, CRUD, and typed collections via serde. The SQL world lives in sqlx-intro.md and diesel-intro.md; key/value caching is in redis.md. MongoDB has no SQL-style migrations, so there is no migration counterpart here.
The User interface is a compile-time-only hint. At runtime the driver hands back whatever BSON Mongo stored, cast to User — TypeScript does no validation, so a document with age: "old" would still be typed as number and blow up later.
findOne resolves to null when there is no match.
Filters and updates are plain JavaScript objects using Mongo operators ($gte, $set, $push).
In Rust the same flow uses a Collection<User> parameterized by your struct. The doc! macro builds BSON filters and updates, and serde does the struct↔BSON conversion. The example below is fully runnable against a local MongoDB (docker run -d -p 27017:27017 mongo:7).
1
// Rust with the official `mongodb` driver
2
usefutures::stream::TryStreamExt;// brings `cursor.try_next()` into scope
3
usemongodb::bson::{doc,oid::ObjectId,DateTime};
4
usemongodb::options::{FindOptions,IndexOptions};
5
usemongodb::{Client,Collection,IndexModel};
6
useserde::{Deserialize,Serialize};
7
8
#[derive(Debug,Serialize,Deserialize)]
9
structUser{
10
// Mongo's primary key is `_id`. We rename so Rust's `id` maps to it, and
11
// skip it when serializing an insert so the server generates the ObjectId.
This compiles and runs with the following dependencies:
Cargo.toml
1
[dependencies]
2
mongodb="3.7"
3
tokio={version="1",features=["full"]}
4
serde={version="1",features=["derive"]}
5
futures="0.3"# for TryStreamExt, used to drain a cursor
Tip: You do not add the bson crate separately. The mongodb crate re-exports a matching BSON version as mongodb::bson. Importing BSON types from mongodb::bson (rather than a standalone bson dependency) avoids the classic “two BSON versions in the tree” mismatch. The one exception is opting into the chrono integration (covered later): there you add bson explicitly, but with the version that matches the driver (bson@2 for mongodb 3.7) so the tree stays unified.
Running the full version of this program (with all the print statements wired in) against a local MongoDB prints real values like:
1
inserted _id is 24 hex chars = 24
2
found one: Ada Lovelace <ada@example.com> age 36
3
adults sorted by age: ["Ada Lovelace (36)"]
4
matched=1 modified=1
5
deleted = 1
The current stable toolchain is Rust 1.96.0 on the 2024 edition; cargo new selects it automatically. The driver shown is mongodb 3.7.
Client::with_uri_str(...) returns a Client that owns an internal connection pool. Like a Node MongoClient, you create one per process and share it — cloning a Client is cheap (it is an Arc internally) and shares the same pool. There is no client.close() you must remember in normal app code; the pool is cleaned up when the last clone is dropped. (Connection-pool sizing and lifecycle across the whole section is covered in connection-pooling.md.)
This is the heart of the ergonomic story. db.collection::<User>("users") produces a Collection<User>. From then on:
insert_one(&user)serializes the User to BSON via serde.
find_one(...) and the cursor from find(...)deserialize BSON back into User.
In TypeScript the Collection<User> generic is erased at runtime; it only annotates types and never inspects data. In Rust the generic is real: User: Deserialize is required at compile time, and the bytes are actually validated against the struct’s shape when a document comes back. (TypeScript generics being erased while Rust monomorphizes is covered in Section 09.)
Mongo’s primary key field is literally named _id and defaults to a 12-byte ObjectId. Two serde attributes make a Rust struct play nicely with it:
#[serde(rename = "_id")] maps the Rust field id to the BSON key _id.
#[serde(skip_serializing_if = "Option::is_none")] on id: Option<ObjectId> means inserts omit the field entirely when it is None, so the server generates the ObjectId. Reads populate it.
insert_one returns the generated id inside res.inserted_id, which is a Bson value; .as_object_id() extracts the ObjectId. To turn a hex string from a URL path into one, use ObjectId::parse_str("...").
doc! { "age": { "$gte": 18 } } expands to a mongodb::bson::Document — an ordered map of String → Bson. It looks like the JavaScript object you would pass to the Node driver, but it is strongly typed: keys are strings and values are Bson variants (Bson::Int32, Bson::String, Bson::Array, …). It is closer to serde_json::json! (see Section 15) than to a plain struct literal.
find(...) returns a Cursor<User>, which is an async stream. The idiomatic drain loop is while let Some(item) = cursor.try_next().await? { ... }. The try_next method comes from the futures::stream::TryStreamExt trait, which you must bring into scope — forgetting that import is the single most common beginner error (see Common Pitfalls). The async-stream model here is the same lazy-future story from Section 11: Async: nothing is fetched until you poll the cursor.
The Node driver takes a plain object for options ({ unique: true }). The Rust driver uses the builder pattern: IndexOptions::builder().unique(true).build(), FindOptions::builder().sort(...).limit(...).build(). Each call to a fluent method like .with_options(opts) attaches them to an operation. This is a recurring Rust idiom for “lots of optional named parameters.”
When the shape is dynamic (logs, migrations, ad-hoc tooling) you can skip the struct and use Collection<Document>. You then read fields with typed getters such as d.get_str("msg") and d.get_i32("n"), each returning a Result. You can also convert between your structs and Document without touching the database using mongodb::bson::to_document / from_document:
The driver leans on serde so the same User struct that flows through your HTTP layer (see Section 16) is also your database model — no second schema definition, no Mongoose-style hydration step. Because the conversion is real code rather than a type annotation, MongoDB’s famously flexible documents become checked at the moment they enter your program: if the database holds a string where your struct expects an i32, you find out at that boundary with a precise error, instead of carrying a wrongly-typed value deep into your logic the way an erased TypeScript interface would. The trade-off is honesty for flexibility — you decide per field how lenient to be (Option<T>, #[serde(default)], custom deserializers).
Mongo stores dates as 64-bit millisecond timestamps, surfaced as mongodb::bson::DateTime. It is deliberately small and Mongo-specific. Out of the box, DateTime::now() and the millisecond accessors cover most needs. If you want the richer chrono API, this is the one case where you add the bson crate explicitly: bring in the version that matches the driver with its chrono-0_4 feature (cargo add bson@2 --features chrono-0_4, since mongodb 3.7 re-exports bson 2.15), then call dt.to_chrono(). Note that to_chrono() is gated behind that feature — with only a bare mongodb = "3.7" dependency the call does not compile (error[E0599]: no method named to_chrono found for struct mongodb::bson::DateTime). Do not reach for std::time::SystemTime in your documents — it has no canonical BSON mapping.
| -------- the method is available for `mongodb::Cursor<mongodb::bson::Document>` here
11
|
12
= help: items from traits can only be used if the trait is in scope
13
help: trait `TryStreamExt` which provides `try_next` is implemented but not in scope; perhaps you want to import it
14
|
15
1 + use futures_util::stream::try_stream::TryStreamExt;
16
|
17
help: there is a method `next` with a similar name
18
|
19
11 - while let Some(_doc) = cursor.try_next().await? {} // method not found
20
11 + while let Some(_doc) = cursor.next().await? {} // method not found
21
|
Note: The compiler suggests futures_util::..., which works, but the idiomatic import is use futures::stream::TryStreamExt; (the futures crate re-exports it). Either resolves the error.
Pitfall 2: Expecting find_one to throw on “not found”
Coming from findOne returning null, it is tempting to treat a missing document as an error. In Rust find_one returns Result<Option<User>>: the Err case is reserved for actual failures (network, auth), and “no match” is Ok(None). Use a match or if let Some(...), and reserve ? for genuine errors. This mirrors the Option/Result split used everywhere in the guide (Section 08: Error Handling).
Pitfall 3: A stored document that disagrees with your struct
To tolerate such documents, model the field as Option<String>, add a custom serde deserializer, or first read as Collection<Document> and coerce manually. The point is you are told — unlike the TypeScript version, where the value is typed number but is really a string until something downstream breaks.
Pitfall 4: Inserting with a populated _id you did not mean to set
If id is Some(...) and you do not use skip_serializing_if, the insert sends that exact _id. Two inserts with the same id collide. Always either keep id: None for new documents (with skip_serializing_if = "Option::is_none"), or let a dedicated write struct omit the field entirely — the same read-model/write-model split shown for Diesel in diesel-intro.md.
Pitfall 5: Assuming the driver clusters writes for you
insert_many(&docs) is one round trip, but update_many/delete_many apply a single filter to many documents — they are not “loop and write each struct.” To update many different documents efficiently, build a bulk write or iterate deliberately. There is no Mongoose-style change tracking that flushes dirty objects on save().
Reuse one Client per process and clone it. It is Arc-backed and owns the pool. Construct it once at startup, store it in your app state, and clone freely into handlers. See connection-pooling.md.
Import BSON from mongodb::bson. This guarantees the BSON version matches the driver and avoids a duplicate-bson dependency in your tree.
Make your structs serde-resilient. Use Option<T> for fields that may be absent, #[serde(default)] for fields added later, and #[serde(rename = "_id")] for the primary key. Flexible schemas are a feature; model the flexibility explicitly.
Prefer typed Collection<T> over Collection<Document> for your domain data, and reserve Document for genuinely dynamic shapes (logs, tooling, aggregation outputs).
Use builders for options and create indexes at startup. Encode unique, TTL, and compound indexes in code (IndexModel) so they are versioned with the app rather than applied by hand.
Atomic mutations belong in the update document. Prefer $inc, $set, $push, and find_one_and_update with ReturnDocument::After over read-modify-write in application code; the latter races under concurrency. (For multi-document atomicity you would use transactions — the SQL analog is in sqlx-transactions.md.)
Keep the database model and the API model close. Because both go through serde, the User you store can often be the User you serialize to JSON for an Axum handler (Section 16) — but split them when storage and API shapes legitimately diverge.
A small inventory repository, the kind of module you would put behind a web handler. It wraps a typed Collection<Product>, exposes intention-revealing methods, and uses find_one_and_update with $inc to reserve stock atomically (the filter in_stock >= qty plus the decrement happen in one operation, so two concurrent reservations cannot oversell). It finishes with an aggregation that sums inventory value. Fully runnable against a local MongoDB.
1
// Cargo.toml:
2
// [dependencies]
3
// mongodb = "3.7"
4
// tokio = { version = "1", features = ["full"] }
5
// serde = { version = "1", features = ["derive"] }
println!("inventory value (cents) = {}",d.get_i64("total").unwrap_or(0));
111
}
112
113
Ok(())
114
}
Real program output (the _id is a fresh ObjectId, so its hex value varies per run; 3 × 7999 = 23997):
1
created id = 6a1d7433288945ef596f5e94
2
by_sku: Mechanical Keyboard @ 7999 cents, stock 5
3
after reserving 2: stock 3
4
over-reserve returned None = true
5
inventory value (cents) = 23997
Every method returns mongodb::error::Result<T>, so callers compose failures with ?, and “not found” stays an honest None instead of a thrown exception — the same error story as the rest of your Rust code, and the opposite of Mongoose where a missing document and a connection failure both surface as a rejected promise you have to disambiguate by hand.
Tip: To configure the connection (pool size, timeouts, app name) parse the URI into ClientOptions first:
Objective: Define a serde-mapped document model and round-trip it through a typed collection.
Instructions: Define a Note struct with an optional _id (renamed and skipped when None), a title, a body, a Vec<String> of tags, and a created_at: DateTime. Insert five notes, then find_one the note titled "Note 1" and print its title.
Objective: Build a paginated query and drain the cursor into a Vec.
Instructions: Write async fn page(coll: &Collection<Note>, page: u64, per: i64) -> mongodb::error::Result<Vec<String>> that returns the titles of one page of notes, sorted by created_at ascending, using FindOptions with .skip(page * per).limit(per). Print page 0 and page 1 with two items each.
Solution
1
usefutures::stream::TryStreamExt;
2
usemongodb::bson::doc;
3
usemongodb::options::FindOptions;
4
usemongodb::Collection;
5
// (assumes the `Note` struct and a populated `notes` collection from Exercise 1)
Objective: Use update_one with upsert(true) so a write either updates an existing document or inserts a new one, and detect which happened.
Instructions: Write async fn upsert_note(coll: &Collection<Note>, title: &str, body: &str) -> mongodb::error::Result<bool> that matches on title, $sets the body, and uses $setOnInsert to populate title/tags/created_at only when inserting. Return true when a brand-new document was created. Call it once for an existing title and once for a new one.
Solution
1
usemongodb::bson::{doc,DateTime};
2
usemongodb::options::UpdateOptions;
3
usemongodb::Collection;
4
// (assumes the `Note` struct and a populated `notes` collection)
$setOnInsert is the key detail: it applies its fields only on the insert branch, so updating an existing note never resets its created_at. The verified output is false for the first call and true for the second.