| name | mongodb |
| description | Use when writing async MongoDB queries with Motor, designing aggregation pipelines, creating indexes, running multi-document transactions, or working with adk.state in Agentex agents. |
MongoDB — Async Patterns with Motor
Async MongoDB via Motor, aggregation pipelines, and index design.
When to Activate
- Writing async MongoDB queries with Motor
- Designing aggregation pipelines (
$match, $group, $lookup, $unwind)
- Creating indexes (compound, text, TTL, sparse, partial)
- Running multi-document transactions
- Watching for real-time changes with change streams
- Working with
adk.state (Agentex per-task state backed by MongoDB)
- Designing document schemas for flexible or hierarchical data
Connection
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
client = AsyncIOMotorClient("mongodb://localhost:27017")
db: AsyncIOMotorDatabase = client["mydb"]
client = AsyncIOMotorClient(
"mongodb://user:pass@host1:27017,host2:27017/mydb?replicaSet=rs0&authSource=admin"
)
client.close()
Collections are accessed as attributes — no schema declaration needed:
users = db["users"]
orders = db.orders
CRUD
from datetime import datetime, timezone
from bson import ObjectId
result = await db.users.insert_one({
"email": "alice@example.com",
"name": "Alice",
"role": "user",
"created_at": datetime.now(timezone.utc),
})
inserted_id = result.inserted_id
result = await db.users.insert_many([
{"email": "bob@example.com", "name": "Bob"},
{"email": "carol@example.com", "name": "Carol"},
])
user = await db.users.find_one({"email": "alice@example.com"})
user = await db.users.find_one({"_id": ObjectId("64a...")})
cursor = db.users.find({"role": "admin"}).sort("created_at", -1).skip(0).limit(20)
users = await cursor.to_list(length=None)
count = await db.users.count_documents({"role": "admin"})
estimated = await db.users.estimated_document_count()
result = await db.users.update_one(
{"_id": ObjectId("64a...")},
{"$set": {"role": "admin", "updated_at": datetime.now(timezone.utc)}},
)
matched = result.matched_count
modified = result.modified_count
await db.users.update_many(
{"role": "user", "created_at": {"$lt": cutoff_date}},
{"$set": {"tier": "legacy"}},
)
await db.users.update_one(
{"email": "dave@example.com"},
{"$setOnInsert": {"created_at": datetime.now(timezone.utc)},
"$set": {"name": "Dave", "role": "user"}},
upsert=True,
)
await db.users.delete_one({"_id": ObjectId("64a...")})
await db.users.delete_many({"status": "inactive", "created_at": {"$lt": cutoff}})
updated = await db.users.find_one_and_update(
{"_id": ObjectId("64a...")},
{"$inc": {"login_count": 1}},
return_document=True,
)
Query Operators
{"age": {"$gt": 18, "$lte": 65}}
{"status": {"$in": ["active", "pending"]}}
{"status": {"$nin": ["banned", "deleted"]}}
{"score": {"$ne": 0}}
{"$and": [{"role": "admin"}, {"active": True}]}
{"$or": [{"email": {"$regex": "@company.com"}}, {"role": "admin"}]}
{"$not": {"status": "banned"}}
{"tags": {"$all": ["python", "async"]}}
{"tags": {"$elemMatch": {"$gt": 10, "$lt": 20}}}
{"tags.2": "python"}
{"phone": {"$exists": True}}
{"age": {"$type": "int"}}
{"email": {"$regex": "^admin", "$options": "i"}}
{"address.city": "New York"}
{"address.zip": {"$in": ["10001", "10002"]}}
Update Operators
{"$set": {"name": "Alice", "role": "admin"}}
{"$unset": {"temp_token": "", "legacy_field": ""}}
{"$inc": {"login_count": 1, "score": -5}}
{"$push": {"tags": "python"}}
{"$push": {"events": {"$each": ["a", "b"], "$slice": -100}}}
{"$addToSet": {"permissions": "write"}}
{"$pull": {"tags": "deprecated"}}
{"$pull": {"events": {"type": "click"}}}
{"$setOnInsert": {"created_at": datetime.now(timezone.utc)}}
Aggregation Pipeline
pipeline = [
{"$match": {"created_at": {"$gte": start_date}}},
{"$group": {
"_id": "$status",
"count": {"$sum": 1},
"total_revenue": {"$sum": "$total"},
"avg_order": {"$avg": "$total"},
}},
{"$sort": {"total_revenue": -1}},
]
results = await db.orders.aggregate(pipeline).to_list(None)
pipeline = [
{"$match": {"role": "admin"}},
{"$lookup": {
"from": "orders",
"localField": "_id",
"foreignField": "user_id",
"as": "orders",
}},
{"$addFields": {"order_count": {"$size": "$orders"}}},
{"$project": {"name": 1, "email": 1, "order_count": 1, "_id": 0}},
]
pipeline = [
{"$unwind": "$items"},
{"$group": {
"_id": "$items.product_id",
"total_sold": {"$sum": "$items.quantity"},
}},
]
pipeline = [
{"$match": {"status": "active"}},
{"$facet": {
"by_role": [
{"$group": {"_id": "$role", "count": {"$sum": 1}}},
],
"total": [
{"$count": "count"},
],
"recent": [
{"$sort": {"created_at": -1}},
{"$limit": 5},
{"$project": {"name": 1, "email": 1}},
],
}},
]
pipeline = [
{"$bucket": {
"groupBy": "$total",
"boundaries": [0, 50, 100, 500, 1000],
"default": "1000+",
"output": {"count": {"$sum": 1}, "avg": {"$avg": "$total"}},
}},
]
Indexes
async def create_indexes(db):
await db.users.create_index("email", unique=True)
await db.orders.create_index([("user_id", 1), ("status", 1), ("created_at", -1)])
await db.articles.create_index([("title", "text"), ("body", "text")])
await db.sessions.create_index("expires_at", expireAfterSeconds=0)
await db.users.create_index("stripe_customer_id", sparse=True, unique=True)
await db.orders.create_index(
"created_at",
partialFilterExpression={"status": "active"},
)
In Agentex, indexes are defined in src/config/mongodb_indexes.py and created on startup automatically.
Transactions (multi-document)
async with await client.start_session() as session:
async with session.start_transaction():
await db.accounts.update_one(
{"_id": from_id},
{"$inc": {"balance": -amount}},
session=session,
)
await db.accounts.update_one(
{"_id": to_id},
{"$inc": {"balance": amount}},
session=session,
)
Change Streams (real-time)
async def watch_orders():
pipeline = [{"$match": {"operationType": {"$in": ["insert", "update"]}}}]
async with db.orders.watch(pipeline) as stream:
async for change in stream:
op = change["operationType"]
doc = change.get("fullDocument")
keys = change.get("updateDescription", {}).get("updatedFields", {})
yield op, doc, keys
Requires replica set. Use change streams to push updates to SSE clients without polling.
Agentex adk.state Pattern
adk.state is MongoDB-backed per-task state storage. Under the hood it's a document per (task_id, agent_id).
from agentex.lib import adk
from project.models import SummarizerState
await adk.state.create(
task_id=task_id,
agent_id=agent_id,
data=SummarizerState().model_dump(),
)
raw = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id)
state = SummarizerState(**raw.data)
state.total_processed += len(batch)
await adk.state.update(
task_id=task_id,
agent_id=agent_id,
data=state.model_dump(),
)
Always load → mutate → save in sequence. Never hold state in workflow memory — Temporal replays will lose it.
Document Design Tips
| Pattern | When | Example |
|---|
| Embed sub-documents | Read together always | order.items[] inside order doc |
| Reference (store ID) | Independent lifecycle, large sub-docs | order.user_id → users collection |
| Bucket pattern | Time-series data, many small writes | One doc per hour with readings[] array |
| Computed fields | Expensive aggregations read often | Store order_count on user doc, update with $inc |
| Schema versioning | Evolving document shape | Add schema_version field, migrate lazily |
Query Optimization
EXPLAIN — read the query plan
plan = await db.orders.find({"user_id": uid, "status": "active"}).explain()
print(plan["queryPlanner"]["winningPlan"])
stats = await db.orders.find({"user_id": uid}).explain("executionStats")
examined = stats["executionStats"]["totalDocsExamined"]
returned = stats["executionStats"]["totalDocsReturned"]
Covered queries — zero document fetch
A query is covered when the index contains all projected fields — MongoDB never reads the actual document:
cursor = db.orders.find(
{"user_id": uid, "status": "active"},
{"_id": 0, "user_id": 1, "status": 1, "total": 1},
)
Index hints
cursor = db.orders.find({"user_id": uid}).hint([("user_id", 1), ("created_at", -1)])
cursor = db.orders.find({}).hint([("$natural", 1)])
Projection — only fetch what you need
users = await db.users.find({}, {"name": 1, "email": 1, "_id": 0}).to_list(100)
users = await db.users.find({}).to_list(100)
names = [u["name"] for u in users]
Schema Design Patterns
Embed vs. Reference Decision
| Signal | Embed | Reference |
|---|
| Access pattern | Always read together | Read independently |
| Cardinality | One-to-few (≤100) | One-to-many (>100) or unbounded |
| Write pattern | Updated together | Updated independently |
| Document size | Sub-docs are small | Sub-docs are large or growing |
| Sharing | Only one parent | Shared across multiple parents |
{
"_id": ObjectId("..."),
"user_id": ObjectId("..."),
"total": 149.99,
"items": [
{"product_id": "p-1", "qty": 2, "price": 49.99},
{"product_id": "p-2", "qty": 1, "price": 50.01},
]
}
{
"_id": ObjectId("..."),
"product_id": ObjectId("..."),
"user_id": ObjectId("..."),
"rating": 4,
"body": "Great product.",
}
Bucket Pattern — time-series
{"sensor_id": "s-1", "ts": datetime(...), "temp": 22.4}
{
"sensor_id": "s-1",
"hour": datetime(2026, 5, 11, 14, 0, 0, tzinfo=timezone.utc),
"count": 60,
"readings": [22.4, 22.5, 22.3, ...],
"min": 22.3, "max": 22.7, "avg": 22.5,
}
Schema Versioning
async def get_user(user_id: ObjectId) -> dict:
doc = await db.users.find_one({"_id": user_id})
version = doc.get("schema_version", 1)
if version == 1:
doc = migrate_v1_to_v2(doc)
await db.users.update_one(
{"_id": user_id},
{"$set": {"preferences": doc["preferences"], "schema_version": 2}}
)
return doc
TypeScript Patterns (Node.js Driver)
import { MongoClient, ObjectId, type Db } from "mongodb";
const client = new MongoClient("mongodb://localhost:27017", {
maxPoolSize: 20,
serverSelectionTimeoutMS: 5000,
});
await client.connect();
const db: Db = client.db("mydb");
const user = await db.collection("users").findOne({ email: "alice@example.com" });
const users = await db.collection("users")
.find({ role: "admin" })
.sort({ created_at: -1 })
.skip(page * limit)
.limit(limit)
.toArray();
const { insertedId } = await db.collection("users").insertOne({
email: "alice@example.com",
role: "user",
created_at: new Date(),
});
await db.collection("users").updateOne(
{ _id: new ObjectId(id) },
{ $set: { role: "admin", updated_at: new Date() } }
);
const results = await db.collection("orders").aggregate([
{ $match: { status: "completed" } },
{ $group: { _id: "$user_id", total: { $sum: "$amount" }, count: { $sum: 1 } } },
{ $sort: { total: -1 } },
{ $limit: 10 },
]).toArray();
const session = client.startSession();
try {
await session.withTransaction(async () => {
await db.collection("accounts").updateOne(
{ _id: fromId }, { $inc: { balance: -amount } }, { session }
);
await db.collection("accounts").updateOne(
{ _id: toId }, { $inc: { balance: amount } }, { session }
);
});
} finally {
await session.endSession();
}
await db.collection("users").createIndex({ email: 1 }, { unique: true });
await db.collection("orders").createIndex({ user_id: 1, created_at: -1 });
await db.collection("sessions").createIndex({ expires_at: 1 }, { expireAfterSeconds: 0 });
Red Flags
- No index on query filter or sort fields — MongoDB performs a collection scan for every unindexed query;
find({"user_id": x}) on a million-document collection takes seconds without an index on user_id
- Unbounded
find() in production — db.collection.find({}) without .limit() loads the entire collection into memory; always add .limit(N) and paginate with a cursor
$match not as the first pipeline stage — aggregation stages before $match process every document before filtering; placing $match first lets MongoDB use indexes and dramatically reduces the work for subsequent stages
datetime.utcnow() instead of datetime.now(timezone.utc) — utcnow() returns a naive datetime with no timezone info and is deprecated in Python 3.12; use datetime.now(timezone.utc) to get a timezone-aware UTC datetime that Motor stores correctly
- Transactions without a replica set —
client.start_session() multi-document transactions require a replica set (or mongos); on a standalone instance they raise a server error; use a replica set even in development (mongo --replSet rs0)
- Embedding unbounded arrays — pushing to a
tags[] or events[] array without a $slice limit grows the document indefinitely, eventually hitting the 16 MB BSON document size limit; cap arrays at creation time using $push with $slice
- Holding state in workflow memory instead of
adk.state — Temporal replays recreate the workflow from scratch; any in-memory state not persisted to MongoDB via adk.state is lost on replay, causing the workflow to behave differently than the first execution
Checklist