When JSON feels too loose and SQL feels too rigid
You are building a leaderboard for a multiplayer game. Scores arrive in bursts. Players have different stats depending on the game mode. Sometimes a player has a weapon field, sometimes they have a spell field. A rigid SQL table forces you to add nullable columns for every new game mechanic, or you end up with a messy jsonb blob that defeats the purpose of a schema.
MongoDB offers a middle ground. It stores documents that look like JSON, so you can add fields on the fly without migrating the whole database. But Rust refuses to compile if your types don't match. You get the flexibility of a document store with the safety of a statically typed language, provided you bridge the gap correctly.
The mongodb crate handles the network, connection pooling, and serialization. serde translates your Rust structs into BSON, the binary format MongoDB uses on the wire. Together, they let you treat database documents as strongly typed Rust values.
The BSON bridge
MongoDB does not store text JSON. It stores BSON, which stands for Binary JSON. BSON adds types that plain JSON lacks, like ObjectId, Decimal128, and precise date handling. It also encodes data in a binary format that is faster to parse and preserves type information across languages.
Rust structs map naturally to BSON documents. A struct field becomes a document key. The field's value becomes the document value. The serde crate does the heavy lifting. When you call insert_one, serde walks your struct, converts each field to its BSON representation, and hands the result to the driver. When you call find_one, the driver receives BSON, serde deserializes it back into your struct, and you get a typed value.
This mapping is strict. If your Rust struct expects a u32 for score but the database contains a string "100", deserialization fails. Rust forces you to handle the mismatch at compile time or runtime, rather than silently corrupting data. This is a feature. It catches schema drift early.
Convention aside: the community prefers the bson-3 feature flag on the mongodb crate. This aligns the driver with the modern bson crate version that supports serde 1.0 and the latest BSON spec. Older versions of the driver used a different BSON implementation that is now legacy. Stick with bson-3 unless you are maintaining ancient code.
Minimal setup
Add the dependencies to your Cargo.toml. The mongodb crate requires an async runtime. tokio is the standard choice.
[dependencies]
mongodb = { version = "3.3.0", default-features = false, features = ["bson-3", "compat-3-3-0", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.0", features = ["full"] }
The rustls-tls feature enables TLS support using rustls, a pure Rust TLS implementation. This avoids pulling in OpenSSL, which simplifies cross-compilation. The compat-3-3-0 feature provides backward compatibility helpers for code written against older driver versions.
Here is a minimal program that connects and inserts a document.
use mongodb::{Client, bson::doc};
use serde::{Deserialize, Serialize};
/// Represents a player in the leaderboard.
#[derive(Debug, Deserialize, Serialize)]
struct Player {
/// MongoDB requires an _id field. We rename the Rust field to match.
#[serde(rename = "_id")]
id: u32,
name: String,
score: i64,
}
#[tokio::main]
async fn main() {
// Connect to the local MongoDB instance.
// unwrap is used for brevity; production code should handle errors.
let client = Client::with_uri_str("mongodb://127.0.0.1:27017").await.unwrap();
// Get a handle to the "game" database and "players" collection.
// These calls are lazy; they do not hit the network yet.
let collection = client.database("game").collection::<Player>("players");
// Create a player struct.
let player = Player {
id: 1,
name: "Rustacean".to_string(),
score: 100,
};
// Insert the document. This triggers the network request.
collection.insert_one(player).await.unwrap();
println!("Inserted player successfully.");
}
The #[serde(rename = "_id")] attribute is crucial. MongoDB mandates that every document has an _id field. If your struct lacks a field that serializes to _id, the driver generates an ObjectId automatically. If you want to control the ID, you must provide it and rename the field.
Convention aside: use Rc::clone(&data) style explicitness when cloning handles, even though collection.clone() works. The community often writes collection.clone() for collections because the clone is cheap, but being explicit about cloning signals intent. For Client and Collection, cloning is extremely cheap because they are just handles to a shared connection pool. You can clone them freely without performance penalty.
How the driver actually works
The Client struct holds a connection pool. When you create a client, the driver opens multiple TCP connections to the MongoDB server in the background. This pool is shared across all operations. Cloning a Client gives you another handle to the same pool. It does not create a new connection.
Calling database() and collection() returns handles. These are lazy. The driver does not verify that the database or collection exists until you perform an operation. This design allows you to pass collection handles around your application without incurring network overhead.
When you call insert_one, the driver takes a connection from the pool, serializes the document to BSON, sends the command, and waits for the response. If the server returns an error, the driver translates it into a Rust error type. If the operation succeeds, the driver returns a result struct.
The async nature of the driver means you must run this code inside an async runtime. If you try to call .await in a synchronous function, the compiler rejects you with an error about futures. You need #[tokio::main] or an equivalent runtime setup.
Section closer: Clone the client, not the anxiety. Handles are cheap. Pass them around freely.
Realistic workflow: query and update
Real applications do more than insert. You need to query documents and update them. The doc! macro is the standard way to build BSON documents for filters and updates without writing verbose constructor calls.
use mongodb::{Client, bson::doc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
struct Player {
#[serde(rename = "_id")]
id: u32,
name: String,
score: i64,
}
#[tokio::main]
async fn main() {
let client = Client::with_uri_str("mongodb://127.0.0.1:27017").await.unwrap();
let collection = client.database("game").collection::<Player>("players");
// Query for a player by name.
// The doc! macro creates a BSON document from key-value pairs.
let filter = doc! { "name": "Rustacean" };
// find_one returns Option<Player>. None means no document matched.
let result = collection.find_one(filter).await.unwrap();
match result {
Some(player) => println!("Found player: {:?}", player),
None => println!("Player not found."),
}
// Update the player's score.
// The update document uses the $set operator to modify only the score field.
let update = doc! { "$set": { "score": 200 } };
// update_one modifies the first document that matches the filter.
let update_result = collection
.update_one(doc! { "id": 1 }, update)
.await
.unwrap();
println!("Modified count: {}", update_result.modified_count);
}
The find_one method returns an Option<T>. This is idiomatic Rust. If no document matches, you get None. If a document matches but deserialization fails, you get an error. This forces you to handle the missing case explicitly.
The update_one method uses MongoDB's update operators. The $set operator replaces the value of a field. If you pass a raw document without operators, MongoDB replaces the entire document. Using operators is safer for partial updates.
Convention aside: prefer doc! over manual bson::Document::insert chains. The macro is concise, readable, and the community uses it everywhere. It also catches typos in keys at compile time if you use typed wrappers, though the raw macro accepts string literals.
Pitfalls and compiler traps
MongoDB integration introduces specific failure modes. Knowing them saves debugging time.
The _id field is the most common trap. Every document must have an _id. If your struct omits it, the driver generates an ObjectId. If you later try to query by a numeric ID, you will find nothing because the database stored an ObjectId. Always decide on your ID strategy upfront. Use u32 for simple counters, ObjectId for distributed systems, or String for external keys. Map it with #[serde(rename = "_id")].
Type mismatches cause deserialization errors. If the database contains a null value for a field that is not Option<T> in Rust, serde fails. MongoDB allows sparse fields. If a document lacks a field, serde can fill it with a default value using #[serde(default)]. Without this, missing fields cause errors.
#[derive(Deserialize)]
struct Player {
#[serde(rename = "_id")]
id: u32,
name: String,
// default prevents error if "score" is missing in the document
#[serde(default)]
score: i64,
}
If you forget to derive Serialize or Deserialize, the compiler rejects your code with E0277 (the trait bound Player: Serialize is not satisfied). This error points directly to the struct. Add the derive macros.
Connection strings often contain secrets. Hardcoding credentials in source code is a security risk. Use environment variables or a configuration file. The Client::with_uri_str method accepts the full connection string, including username and password. Load the string from std::env::var("MONGO_URI") in production.
Section closer: Don't hardcode credentials. The compiler won't stop you, but your future self will.
Decision matrix
Use the mongodb crate when you need a full-featured driver with connection pooling, async support, and automatic BSON serialization for your structs. Use the bson crate directly when you are building a custom abstraction or need to manipulate raw documents without the overhead of the full driver. Reach for Sea-ORM or Diesel when your data has rigid relationships and you prefer SQL's query language over MongoDB's document model. Pick raw SQL queries when you need maximum control over execution plans or are migrating a legacy database that refuses to change schema.