pymongo
PyMongo is MongoDB's own Python driver, and the distribution actually ships three packages: pymongo (the driver), bson (the binary document format MongoDB speaks on the wire), and gridfs (chunked storage for files larger than the 16MB document limit). You create a MongoClient, index into it to get a database and a collection, and then call methods like insert_one, find, update_many and aggregate that map almost one-to-one onto MongoDB commands. Documents go in and come out as plain Python dicts. Since 4.9 the same package also ships AsyncMongoClient, an asyncio API with the same method names, which is what replaced Motor.
The only sensible way to reach MongoDB from Python, maintained by the same company that ships the server, and now covering both sync and async in one package. It is a driver and nothing more, so bring your own validation layer if you want typed documents.
Use it if
- You are talking to MongoDB or Atlas from Python and want the driver the server team maintains alongside the database itself
- You want a thin mapping over MongoDB commands rather than an ORM layer inventing its own query language on top
- You need asyncio: AsyncMongoClient ships in the same package with the same method names, and Motor is now deprecated in favour of it
- You need driver features that only the official client implements, such as client-side field level encryption, change streams, GridFS, or MONGODB-AWS and Kerberos auth
- You want documents as plain dicts so you can hand them to pandas, Pydantic, or a JSON serializer without unwrapping model objects
- You want schema enforcement or model classes. PyMongo has none: a typo in a field name inside a filter is not an error, it just matches zero documents, and a typo inside an update writes a new field. mongoengine or beanie exist for a reason.
- Your data is relational. Joins in MongoDB mean $lookup inside an aggregation pipeline, which is slower and far harder to read than a SQL join, and the driver cannot rescue a schema that wanted foreign keys.
- You run a standalone mongod and need multi-document transactions. Sessions and transactions require a replica set or a sharded cluster; a single-node server will refuse them, so local development needs a one-member replica set.
- You fork worker processes. MongoClient is not fork-safe, so a client created before a fork (gunicorn preload, multiprocessing) leaves the child with a corrupted connection pool. You have to create the client after the fork.
- You are upgrading from PyMongo 3. Version 4.0 deleted save(), insert(), update(), remove() and count(), and a decade of tutorials still uses them. The port is mechanical but it touches every data-access call site.
- You expect GitHub issues to work. Bug reports go to MongoDB JIRA, so the GitHub tracker sits at zero open issues and gives you no signal at all about what is broken.
Setup reality
python -m pip install pymongo is the whole install and dnspython comes with it, so mongodb+srv:// Atlas URIs work out of the box. Two traps follow. First, never pip install bson: PyMongo bundles its own bson package and the unrelated PyPI package of that name shadows it and breaks imports in ways that look like driver bugs. Second, most of the interesting auth and compression paths are extras you have to ask for by name: pymongo[aws] for MONGODB-AWS, pymongo[gssapi] for Kerberos, pymongo[ocsp] for certificate revocation checking, pymongo[snappy] and pymongo[zstd] for wire compression, and pymongo[encryption] for client-side field level encryption, which additionally needs the mongocryptd binary or the shared crypt library on the host. Also remember that MongoClient() does not connect: it returns immediately and the first real network error surfaces 30 seconds later on your first operation, because serverSelectionTimeoutMS defaults to 30000.
Patterns
Connect and insert a documentconnect-and-insert
from pymongo import MongoClient
client = MongoClient("mongodb://localhost:27017")
db = client["shop"]
orders = db["orders"]
result = orders.insert_one({"sku": "ABC", "qty": 3})
print(result.inserted_id) # ObjectId(...)MongoClient() does no I/O; it starts background monitoring and returns. Create exactly one client per process and share it, and create it after any fork, because the pool is not fork-safe.
Fail fast instead of waiting 30 secondsverify-connection
from pymongo import MongoClient
from pymongo.errors import ServerSelectionTimeoutError
client = MongoClient(uri, serverSelectionTimeoutMS=3000)
try:
client.admin.command("ping")
except ServerSelectionTimeoutError as exc:
raise SystemExit(f"cannot reach mongo: {exc}")serverSelectionTimeoutMS defaults to 30000, so a bad host or firewall rule looks like a 30 second hang on your first query rather than an immediate error. Lower it for health checks and startup probes.
Find documents with projection, sort and limitquery-documents
import pymongo
doc = orders.find_one({"sku": "ABC"})
cursor = (
orders.find({"qty": {"$gte": 2}}, {"sku": 1, "qty": 1, "_id": 0})
.sort("qty", pymongo.DESCENDING)
.limit(20)
)
for doc in cursor:
print(doc)find() returns a lazy cursor, so nothing is sent until you iterate. A cursor can only be consumed once; wrap it in list() if you need to walk the results twice.
Update documents and upsertupdate-and-upsert
res = orders.update_one(
{"sku": "ABC"},
{"$set": {"qty": 10}, "$currentDate": {"updated_at": True}},
upsert=True,
)
print(res.matched_count, res.modified_count, res.upserted_id)
orders.update_many({"status": "new"}, {"$set": {"status": "queued"}})The second argument must contain update operators. Passing a bare dict like {"qty": 10} raises in PyMongo 4; if you really want whole-document replacement use replace_one.
Batch mixed writes in one round tripbulk-write
from pymongo import InsertOne, UpdateOne, DeleteOne
ops = [
InsertOne({"sku": "NEW", "qty": 1}),
UpdateOne({"sku": "ABC"}, {"$inc": {"qty": 1}}),
DeleteOne({"sku": "OLD"}),
]
result = orders.bulk_write(ops, ordered=False)
print(result.bulk_api_result)ordered=False lets the server keep going after a failed operation and run the rest in parallel; the default ordered=True stops at the first error. Either way, check result rather than assuming everything applied.
Run an aggregationaggregation-pipeline
pipeline = [
{"$match": {"status": "shipped"}},
{"$group": {"_id": "$sku", "total": {"$sum": "$qty"}}},
{"$sort": {"total": -1}},
{"$limit": 10},
]
for row in orders.aggregate(pipeline):
print(row["_id"], row["total"])Stage order is the whole game: put $match and $project before $group so the server filters before it builds groups. Pass allowDiskUse=True when a stage exceeds the 100MB in-memory limit.
Create indexes, including a unique onecreate-indexes
from pymongo import ASCENDING, DESCENDING
orders.create_index([("sku", ASCENDING)], unique=True)
orders.create_index([("status", ASCENDING), ("created_at", DESCENDING)])
orders.create_index("expires_at", expireAfterSeconds=0) # TTL indexcreate_index is idempotent for an identical spec but raises if an index with the same name exists with different options. Creating a unique index on a collection that already has duplicates fails, so clean up first.
Catch the errors that actually happenhandle-write-errors
from pymongo.errors import DuplicateKeyError, OperationFailure
try:
orders.insert_one({"_id": existing_id, "sku": "ABC"})
except DuplicateKeyError:
orders.update_one({"_id": existing_id}, {"$set": {"sku": "ABC"}})
except OperationFailure as exc:
print(exc.code, exc.details)DuplicateKeyError is a subclass of WriteError, which is a subclass of OperationFailure, so order your except clauses narrowest first or the specific handler never runs.
Run multiple writes in a transactiontransactions
with client.start_session() as session:
def transfer(session):
accounts.update_one({"_id": 1}, {"$inc": {"balance": -100}}, session=session)
accounts.update_one({"_id": 2}, {"$inc": {"balance": 100}}, session=session)
session.with_transaction(transfer)Pass session= to every operation inside the callback or it runs outside the transaction and silently commits on its own. with_transaction retries transient errors for you; start_transaction() does not. Needs a replica set or sharded cluster.
Use the asyncio APIasync-client
import asyncio
from pymongo import AsyncMongoClient
async def main():
client = AsyncMongoClient("mongodb://localhost:27017")
orders = client["shop"]["orders"]
await orders.insert_one({"sku": "ABC", "qty": 3})
async for doc in orders.find({"qty": {"$gte": 2}}):
print(doc)
await client.close()
asyncio.run(main())Same method names as the sync client, but everything except find() must be awaited, and find() returns an async cursor you iterate with async for. This is the replacement for Motor, which was deprecated in May 2026.
Watch a collection for changeschange-streams
pipeline = [{"$match": {"operationType": {"$in": ["insert", "update"]}}}]
with orders.watch(pipeline, full_document="updateLookup") as stream:
for change in stream:
print(change["operationType"], change["fullDocument"])Change streams need a replica set and read from the oplog, so a consumer that stops for longer than the oplog window cannot resume. Persist change["_id"] and pass it as resume_after to survive restarts.
Store a file bigger than 16MBgridfs-large-files
import gridfs
fs = gridfs.GridFS(db)
with open("report.pdf", "rb") as fh:
file_id = fs.put(fh, filename="report.pdf", content_type="application/pdf")
data = fs.get(file_id).read()GridFS chunks the file across two collections (fs.files and fs.chunks) to get around the 16MB BSON document limit. It is not a CDN; if you are serving these over HTTP, object storage is almost always the better answer.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| beanie | PyPI | You want async Pydantic models over MongoDB instead of raw dicts |
| mongoengine | PyPI | You want a Django-style declarative ODM with field validation on a synchronous codebase |
| motor | PyPI | Only for existing Tornado or asyncio code you cannot port yet; it was deprecated in May 2026 in favour of AsyncMongoClient |