How to Connect to MongoDB from Rust
MongoDB in Rust is a one-driver ecosystem: mongodb, the official driver maintained by MongoDB Inc. It is async-first (tokio required), pure Rust, and leans on serde for the thing Rust does better than most driver ecosystems -- mapping documents to typed structs at the deserialization boundary instead of passing loose maps around. This guide covers the async API, the sync feature flag, typed vs dynamic documents, and the connection details that actually break in production: lazy connection, SRV strings, auth sources, and pool sizing.
The options
| Library | Model | When to use it | Version (as of July 2026) |
|---|---|---|---|
mongodb | Async driver (tokio), official | Everything | 3.8.0 |
mongodb + sync feature | Blocking wrapper over the same driver | Scripts, CLIs, no async runtime | 3.8.0 |
bson | Document type + serde integration | Pulled in by the driver | 3.1.0 |
There is no serious community alternative to weigh, and no ORM/ODM layer with real adoption -- serde integration fills the role an ODM plays elsewhere. One version note: driver 3.x uses bson 2.x by default for backwards compatibility; enable the bson-3 feature to get the current bson 3.x API. New projects should turn it on. Requires Rust 1.88+.
Connecting
[dependencies]
mongodb = { version = "3.8", features = ["bson-3"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }use mongodb::{bson::doc, Client};
#[tokio::main]
async fn main() -> mongodb::error::Result<()> {
let client = Client::with_uri_str("mongodb://localhost:27017").await?;
// Force a round trip -- see the lazy-connection trap below
client
.database("admin")
.run_command(doc! { "ping": 1 })
.await?;
println!("connected");
Ok(())
}For Atlas, use the SRV form -- it resolves cluster members via DNS and implies TLS:
let uri = "mongodb+srv://appuser:s3cret@cluster0.abc12.mongodb.net/myapp?retryWrites=true&w=majority";
let client = Client::with_uri_str(uri).await?;The lazy-connection trap
Client::with_uri_str does not talk to the server. It parses the URI, spins up the connection machinery, and returns -- a wrong password or unreachable host surfaces later, as a timeout on your first real operation, often deep inside request handling. The ping above turns "mystery timeout at 3 a.m." into "clear error at startup." Do it once when the application boots.
Related: the default serverSelectionTimeoutMS is 30 seconds, so a misconfigured URI makes that first operation hang for half a minute before failing. During development, shorten it (?serverSelectionTimeoutMS=3000) to fail fast.
One client per application
Client is cheap to clone (it is a handle around a shared connection pool) and safe to share across tasks. Create it once and clone the handle everywhere -- creating a client per request rebuilds the pool each time and exhausts server connections under load.
Typed collections: the serde advantage
You can work with dynamic Document values, but the idiomatic pattern is a typed collection:
use mongodb::bson::oid::ObjectId;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct User {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
id: Option<ObjectId>,
name: String,
email: String,
}
let users = client.database("myapp").collection::<User>("users");
let res = users
.insert_one(User { id: None, name: "Ada".into(), email: "ada@example.com".into() })
.await?;
println!("inserted _id: {}", res.inserted_id);
let found = users
.find_one(doc! { "email": "ada@example.com" })
.await?; // -> Option<User>, already deserializedThe _id handling is the part everyone gets wrong once: rename the field with serde, and skip serializing it when None so the server generates the ObjectId. If you serialize id: None without skip_serializing_if, you store a literal null _id -- and the second insert fails with a duplicate key error on _id: null.
Filters and updates use the doc! macro, which is BSON, not JSON -- field references and operators like $set, $gt work as string keys:
users
.update_one(
doc! { "email": "ada@example.com" },
doc! { "$set": { "name": "Ada Lovelace" } },
)
.await?;The classic document-database trap applies here too: replace_one with a partial struct silently drops every field not present in it. For partial changes, use update_one with $set.
Cursors are async streams
find returns a cursor implementing futures::Stream:
use futures::stream::TryStreamExt;
let mut cursor = users.find(doc! {}).await?;
while let Some(user) = cursor.try_next().await? {
println!("{:?}", user);
}Add futures = "0.3" to your dependencies for the TryStreamExt trait.
The sync API
For CLIs and scripts, enable the sync feature and import from mongodb::sync -- same driver, blocking calls, no tokio in your code:
mongodb = { version = "3.8", features = ["sync", "bson-3"] }use mongodb::{bson::doc, sync::Client};
let client = Client::with_uri_str("mongodb://localhost:27017")?;
let db = client.database("admin");
db.run_command(doc! { "ping": 1 }).run()?;Connection details that break in production
authSource. Credentials in the URI authenticate against the database in the path segment; if your user was created in admin (the common case) but your URI path is myapp, authentication fails. Fix: mongodb://user:pass@host:27017/myapp?authSource=admin.
Percent-encode credentials. A password containing @, :, /, or % breaks URI parsing. Encode it (p%40ss for p@ss) or build ClientOptions programmatically and set credentials as struct fields, which avoids the problem entirely.
Atlas IP allowlist. Timeouts against Atlas with correct credentials are almost always the network allowlist, not auth -- Atlas silently drops connections from unlisted IPs. Check it before debugging anything else.
Pool sizing. Default maxPoolSize is 10 per client process. Total connections = pool size x processes x instances; compare against your cluster tier's connection limit (Atlas M10 caps at 1,500) before raising it.
localhost vs ::1. On systems where localhost resolves to IPv6 ::1 first, connecting to a mongod listening only on IPv4 fails with connection refused. Use 127.0.0.1 explicitly.
TLS. mongodb+srv:// implies TLS. For non-SRV URIs add ?tls=true. The driver uses rustls by default; the openssl-tls feature switches to OpenSSL if you need system trust stores.
Common errors
| Error | Likely cause | Fix |
|---|---|---|
| Timeout on first operation | Wrong host/creds surfacing late (lazy connect) | Ping at startup; shorten serverSelectionTimeoutMS |
SCRAM failure: Authentication failed | Wrong authSource | ?authSource=admin |
| Timeout against Atlas, creds correct | IP not in Atlas allowlist | Add your IP/CIDR in Atlas Network Access |
E11000 duplicate key ... _id_ dup key: { _id: null } | Serialized id: None without skip_serializing_if | Add the serde attribute |
Connection refused on localhost | IPv6 ::1 resolution | Use 127.0.0.1 |
| URI parse error | Unencoded special chars in password | Percent-encode or use ClientOptions |
Mako connects to MongoDB alongside PostgreSQL, MySQL, and the rest, with AI-powered autocomplete for exploring collections and writing queries. Try it free at mako.ai.
Skip the terminal. Use Mako.
Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.