What it actually is
DocumentDB is a managed document database that implements the MongoDB API on top of a purpose-built engine. AWS says this itself: the engine is a distributed, fault-tolerant, self-healing storage system, and that is why query plans and explain() output can differ from MongoDB. It is not a MongoDB fork and it does not run MongoDB server code. Your driver connects, speaks the wire protocol, and the service translates.
That framing is the whole article. "Compatible" is a claim about the API surface, not about behavior. The current docs describe DocumentDB 8.0 as having full wire protocol compatibility with MongoDB 8.0, and they say a vast majority of applications, drivers and tools work with little or no change. They also say, in the same pages, that DocumentDB does not support every MongoDB feature. Both statements are true, and your job before a migration is to find out which side of the line your application lives on.
The model
A cluster is one storage volume plus up to 16 instances: one primary that takes writes and up to 15 read replicas. Compute and storage are separate. The storage volume grows automatically and is shared by all instances, so adding a replica does not copy data, and replicas read from the same volume the primary writes to. Replication between instances carries no charge.
The primitives you will touch: the cluster (endpoint for writes), the reader endpoint (spreads reads over replicas), instance endpoints, a subnet group (which subnets and Availability Zones nodes can live in), a cluster parameter group (engine settings such as TLS and the change stream retention), and a security group. DocumentDB lives inside a VPC. There is no public endpoint, so everything in this tutorial that talks to the database runs from a host inside that VPC.
Versions matter more than usual here. The docs list 3.6, 4.0, 5.0 and 8.0 API versions. Per the current release notes, 8.0.0 shipped in April 2026 (the 8.0 API compatibility itself was announced in November 2025), 8.0.1 added SCRAM-SHA-256 authentication and a 256 TiB storage limit, and 8.0.2 on September 28, 2026 added a newer query planner path for aggregation ($setWindowFields, $bucketAuto, $facet, $graphLookup, correlated $lookup), retryable writes, and change stream events for createCollection and createIndex. In place major version upgrades from 3.6 and 4.0 straight to 8.0 became available on August 31, 2026. Because features land per minor version, "supported" is a question you answer against your cluster's exact engine version.
Two other cluster shapes exist. Elastic clusters shard for you (up to 32 shards, with a documented 4 PiB per cluster ceiling for evenly distributed shard keys) and have their own quota table, their own IAM prefix and a lower per-cluster user limit. Serverless, available on 8.0 since May 2026, scales instance capacity in DCUs in half-DCU steps with per-second billing. This article builds an instance-based cluster because it is the default and the one most migrations land on.
What AWS operates: storage replication, failover, patching, backups, the TLS endpoint. What you operate: index design, instance sizing, the client retry logic, and the compatibility gap.
When to use it, when not to
Question | DocumentDB | MongoDB Atlas / self-run MongoDB | DynamoDB |
|---|---|---|---|
API | MongoDB wire protocol, subset | Full MongoDB, the reference | Proprietary key-value and document API |
Where it runs | Your VPC, AWS only | Many clouds, or your own servers | AWS, serverless, no VPC needed |
Query model | Rich queries, aggregation, joins via $lookup | Same, plus every newest feature first | Key access plus limited secondary indexes |
Sizing | Pick instance classes, storage grows | Pick tiers or hardware | On-demand or provisioned capacity |
Pick it when | You have a MongoDB app and want it inside AWS with IAM, VPC and one bill | You need the newest features or multi-cloud | You know your access patterns up front |
Use DocumentDB when you already have a working MongoDB application, you mostly use CRUD, indexes, transactions and the common aggregation stages, and you want the database to be an AWS resource with VPC isolation, KMS encryption and managed backups. Do not use it when you depend on a feature outside the supported surface, when you need to run the same database on another cloud, or when you are starting a new project with no MongoDB heritage and can choose DynamoDB or a relational database on merit. If you have an existing application, treat the compatibility probe later in this article as the first migration step, not the last.
What it costs
Four dimensions, all billed per Region. Instances are billed per second with a 10 minute minimum after a billable status change such as create, modify or delete. Storage is billed per GB-month and grows with your data. I/O is billed per million requests on the Standard configuration. Backup storage is free up to 100 percent of your cluster storage, then it starts at roughly $0.02 per GB-month depending on Region.
From the pricing page for us-east-1: storage is $0.10 per GB-month on Standard and $0.30 per GB-month on I/O-Optimized, I/O is $0.20 per million requests on Standard and included on I/O-Optimized, and the listed r5.large on-demand example is $0.277 per hour Standard versus $0.3047 per hour I/O-Optimized. A t3.medium (2 vCPU, 4 GiB) was quoted at $0.078 per hour in an AWS database blog post, which is why it is the instance in this tutorial; check the pricing page for the current rate in your Region before you start. Serverless is $0.0822 per DCU-hour Standard and $0.0905 on I/O-Optimized.
The one that surprises people is I/O on Standard storage. The docs give a rule of thumb: if I/O is under 25 percent of your total cluster cost, stay on Standard; above that, I/O-Optimized has better price performance. You can switch from Standard to I/O-Optimized once every 30 days, and back at any time, without downtime. Change streams add a second surprise: they are disabled by default and the docs warn that enabling them adds read and write I/O and storage cost.
Free tier reality: new accounts get a one month trial with 750 hours of db.t3.medium, 30 million I/Os, 5 GB of storage and 5 GB of backup. It does not carry over once the month ends.
The limits that bite
From the current quotas page for instance-based clusters:
Limit | Value |
|---|---|
Clusters per Region | 40 |
Instances per Region | 40 |
Read replicas per cluster | 15 |
Cluster and database size | 256 TiB on 8.0 and later, 128 TiB before |
Collection size (excluding indexes) | 32 TiB |
Document size | 16 MiB |
Indexes per collection | 64 |
Index key size | 2,048 bytes |
Keys in a compound index | 32 |
Collections per cluster | 100,000 |
Users per cluster | 1,000 |
Aggregation stages per pipeline | 500 |
The limit that actually shows up first is per instance, not per cluster. A db.t3.medium (4 GiB) allows 1,000 total connections but only 102 active ones, 30 cursors and 50 open transactions. A db.r8g.large (16 GiB) allows 3,400 connections, 1,100 active, 450 cursors and 200 open transactions. Lambda functions that open a client per invocation hit these numbers quickly, and CloudWatch exposes DatabaseConnectionsLimit and DatabaseCursorsLimit so you can see the ceiling.
Behavioral limits worth memorizing: only one index build can run on a collection at a time; TTL deletes are best effort; bulk operations such as updateMany and insertMany are atomic per document, not as a whole, so use a transaction when you need all or nothing; result ordering without an explicit sort is not guaranteed; reverse $natural scans return an error; and on engines before 8.0.2 retryable writes are not supported, so you must set retryWrites=false in the connection string or the driver errors.
Build it
This build creates a one instance cluster, enables a change stream on one collection, watches it from Python, resumes it from a token, and runs a compatibility probe against your engine version. I followed the AWS documentation for every command below but did not execute the build for this article, so treat the outputs as what the docs say to expect and verify each step as you go.
Prerequisites: an AWS account, the AWS CLI v2, Python 3.10 or later, and a host inside the VPC where the cluster will live (an EC2 instance in the same VPC works). Your default VPC needs subnets in at least two Availability Zones; the docs recommend three.
IAM permissions: the managed policy AmazonDocDBFullAccess covers the management side. DocumentDB cluster-management actions use the rds: prefix because the service shares operational technology with RDS. If you use the managed master password option, your identity also needs Secrets Manager read access to fetch it. The elastic cluster policies use a different docdb-elastic: prefix and do not apply here.
Estimated cost: one db.t3.medium for about two hours is a few tens of cents at the $0.078 per hour figure, plus a few cents for storage and I/O. Delete everything in the cleanup section or the instance keeps billing.
Step 1: networking and cluster. Replace the subnet and security group IDs with your own. The security group must allow inbound TCP 27017 from your client host.
export AWS_REGION=us-east-1
aws docdb create-db-subnet-group \
--db-subnet-group-name sl-docdb-subnets \
--db-subnet-group-description "Software Letters tutorial" \
--subnet-ids subnet-aaaa1111 subnet-bbbb2222 subnet-cccc3333
aws docdb create-db-cluster \
--db-cluster-identifier sl-docdb \
--engine docdb \
--engine-version 8.0.0 \
--storage-type standard \
--db-subnet-group-name sl-docdb-subnets \
--vpc-security-group-ids sg-0123456789abcdef0 \
--master-username dbadmin \
--manage-master-user-passwordTwo details from the docs: engine-version defaults to 5.0.0 if you omit it, so set it explicitly, and --manage-master-user-password has DocumentDB generate the password and keep it in Secrets Manager, so you do not pass --master-user-password. The CLI creates no instance with the cluster, so add one:
aws docdb create-db-instance \
--db-instance-identifier sl-docdb-1 \
--db-instance-class db.t3.medium \
--engine docdb \
--db-cluster-identifier sl-docdb
aws docdb wait db-instance-available --db-instance-identifier sl-docdb-1
aws docdb describe-db-clusters --db-cluster-identifier sl-docdb \
--query "DBClusters[0].[Endpoint,MasterUserSecret.SecretArn]" --output textStep 2: set change stream retention. The parameter is change_stream_log_retention_duration, default 3 hours. Create a cluster parameter group, set it, and attach it to the cluster. Keep the 3 hour default for this tutorial and skip this step unless you want longer, with one caution: the docs say the value can be 1 hour to 7 days, but the sample describe output lists allowed values of 3600 to 86400 seconds, so check AllowedValues on your cluster before relying on more than 24 hours.
Step 3: connect from your client host. Download the CA bundle and install the driver.
wget https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
python3 -m pip install pymongo boto3Fetch the credentials and put them in the environment so the password never sits in your shell history:
# creds.py
import json, os, boto3
secret_arn = os.environ["SECRET_ARN"]
sm = boto3.client("secretsmanager")
s = json.loads(sm.get_secret_value(SecretId=secret_arn)["SecretString"])
print(s["username"]) # password stays in s["password"]The connection pattern from the docs uses TLS, the CA file, replicaSet=rs0, and retryWrites=false. Here it is as a reusable helper, with the password URL-encoded:
# conn.py
import json, os, boto3
from urllib.parse import quote_plus
from pymongo import MongoClient
def client():
sm = boto3.client("secretsmanager")
s = json.loads(sm.get_secret_value(SecretId=os.environ["SECRET_ARN"])["SecretString"])
host = os.environ["DOCDB_ENDPOINT"]
uri = (f"mongodb://{quote_plus(s['username'])}:{quote_plus(s['password'])}@{host}:27017/"
"?tls=true&tlsCAFile=global-bundle.pem&replicaSet=rs0"
"&readPreference=secondaryPreferred&retryWrites=false")
return MongoClient(uri, serverSelectionTimeoutMS=5000)Step 4: enable a change stream on one collection and watch it. Change streams are off by default and are enabled with the modifyChangeStreams admin command, scoped to a collection, a database, or everything.
# stream.py
from conn import client
c = client()
c.admin.command({"modifyChangeStreams": 1, "database": "shop", "collection": "orders", "enable": True})
orders = c["shop"]["orders"]
with orders.watch(full_document="updateLookup") as stream:
orders.insert_one({"sku": "A-1", "qty": 1})
ev = stream.try_next()
print("insert:", ev["operationType"] if ev else None)
token = ev["_id"] if ev else None
orders.update_one({"sku": "A-1"}, {"$set": {"qty": 2}})
ev = stream.try_next()
print("update:", ev["operationType"] if ev else None, ev["fullDocument"] if ev else None)
# resume after the insert: only the update should come back
with orders.watch(resume_after=token) as resumed:
ev = resumed.try_next()
print("resumed:", ev["operationType"] if ev else None)try_next can return None on the first call before the server has scanned the log, so rerun the script or loop a few times if you see None. The resume token is the _id of the last event you processed, and you can also resume from a timestamp with startAtOperationTime. Events are kept for the retention window only, so a consumer that is down longer than 3 hours loses its place. The docs also note that on engine 3.6 and 4.0 change stream cursors open only on the primary, while 5.0 and later can open them on secondaries.
Step 5: run the compatibility probe. This is the part the plan calls a compatibility test suite. I did not find an official pass or fail script from AWS, so this one is a probe you extend with your own queries. It runs each statement, records whether the engine accepted it, and prints a table. Replace the probes with the operations your application uses.
# probe.py
from conn import client
c = client()
db = c["probe"]
col = db["items"]
col.drop()
col.insert_many([{"_id": i, "a": i % 5, "tags": ["x", "y"], "n": i * 1.5} for i in range(50)])
def _txn(cl):
with cl.start_session() as s:
with s.start_transaction():
cl["probe"]["items"].insert_one({"_id": 9999}, session=s)
s.abort_transaction()
probes = {
"group + sort": lambda: list(col.aggregate([{"$group": {"_id": "$a", "t": {"$sum": "$n"}}}, {"$sort": {"t": -1}}])),
"$facet": lambda: list(col.aggregate([{"$facet": {"lo": [{"$match": {"a": 0}}], "hi": [{"$match": {"a": 4}}]}}])),
"$bucketAuto": lambda: list(col.aggregate([{"$bucketAuto": {"groupBy": "$n", "buckets": 4}}])),
"$setWindowFields": lambda: list(col.aggregate([{"$setWindowFields": {"sortBy": {"_id": 1}, "output": {"run": {"$sum": "$n", "window": {"documents": ["unbounded", "current"]}}}}}])),
"$graphLookup": lambda: list(col.aggregate([{"$graphLookup": {"from": "items", "startWith": "$a", "connectFromField": "a", "connectToField": "_id", "as": "g", "maxDepth": 1}}, {"$limit": 1}])),
"$median accumulator": lambda: list(col.aggregate([{"$group": {"_id": None, "m": {"$median": {"input": "$n", "method": "approximate"}}}}])),
"reverse natural": lambda: list(col.find().sort([("$natural", -1)]).limit(1)),
"transaction": lambda: _txn(c),
}
print(f"{'probe':24} result")
for name, fn in probes.items():
try:
fn()
print(f"{name:24} ok")
except Exception as e:
print(f"{name:24} REJECTED: {str(e)[:70]}")
print("engine:", c["probe"].command("buildInfo").get("version"))
c["probe"]["items"].drop()Read the output as follows. Probes that print ok are accepted by this engine version. REJECTED lines are the gap. Based on the release notes you should see reverse $natural rejected on every version, and the newer aggregation stages depending on your minor version (the 8.0.2 release notes list $setWindowFields, $bucketAuto, $facet and $graphLookup), so the same script run against a 5.0 cluster and an 8.0.2 cluster is a quick way to see the version effect. The $median probe depends on the 8.0.1 operator additions, which the July 2026 announcement lists, so a rejection there points at an engine on an older minor.
Verification: the stream.py script should print insert, then update with the full document showing qty 2, then resumed with update. If resumed prints insert, you resumed from the wrong token. If probe.py prints a connection error, the cause is almost always the security group or running outside the VPC.
When it breaks
"Retryable writes are not supported" means your connection string omitted retryWrites=false on an engine older than 8.0.2. Add the flag or upgrade the minor version.
A timeout on connect, not an auth error, means the network path is wrong: the security group does not allow 27017 from your host, or the host is outside the VPC. DocumentDB has no public endpoint.
An authentication failure after you rotate or regenerate the managed password means your client cached the old secret. Fetch it again from Secrets Manager.
"MongoServerError" on a query that works on MongoDB is the compatibility gap, and the usual suspects are in the functional differences page: a reverse $natural scan, an $elemMatch inside $all, a $regex without the hint DocumentDB needs to use an index, a sparse index that the query does not use because it lacks $exists, or a $vectorSearch used as a standalone operator rather than inside $search. Run it through the probe first.
A change stream that stops returning events after a long consumer outage has aged out of its retention window. Re-seed from the current state, then watch from a fresh startAtOperationTime. Another quiet failure: the docs warn that long running writes such as updateMany and deleteMany can temporarily stall event writing, so a spike in stream latency after a bulk operation is expected.
An index build that fails with a conflict is the one build per collection rule. Serialize them.
Cleanup
Delete the instance, then the cluster, then the subnet group. If you enabled deletion protection, disable it first. The commands skip the final snapshot, which is fine for a tutorial and wrong for anything real.
aws docdb delete-db-instance --db-instance-identifier sl-docdb-1
aws docdb wait db-instance-deleted --db-instance-identifier sl-docdb-1
aws docdb modify-db-cluster --db-cluster-identifier sl-docdb --no-deletion-protection
aws docdb delete-db-cluster --db-cluster-identifier sl-docdb --skip-final-snapshot
aws docdb wait db-cluster-deleted --db-cluster-identifier sl-docdb
aws docdb delete-db-subnet-group --db-subnet-group-name sl-docdb-subnets
aws docdb describe-db-clusters --query "DBClusters[].DBClusterIdentifier"The last command should print an empty list for your Region. Also delete any manual snapshots you took, since backup storage beyond 100 percent of cluster storage bills monthly. A cluster that is stopped still holds storage, and a stop lasts at most 7 days before AWS starts it again, so deleting is the only way to reach a zero bill.

