MongoDB is the leading database for document data modeling, with its Atlas service available on AWS, Azure, and Google Cloud. Its popularity has led to the development of compatible APIs by other vendors, like Amazon DocumentDB (with MongoDB compatibility), highlighting MongoDB's importance in modern applications. Microsoft did the same for CosmosDB and developed a MongoDB emulation on PostgreSQL called DocumentDB, now part of the Linux Foundation.
AWS has joined the project. While today Amazon DocumentDB uses its own Aurora‑based proprietary engine, AWS’s participation opens the possibility that, in the future, the managed service could leverage this PostgreSQL‑based extension.
An emulation cannot truly replace MongoDB, which was designed to store, index, and process documents with flexible schema natively instead of using fixed-size blocks and relational tables, but may help in their transition. This article tests a simple query across three options: native MongoDB, PostgreSQL with the DocumentDB extension, and Oracle Database’s emulation - another emulation on top another RDBMS. They encounter similar challenges: implementing document semantics on top of a row-based engine. The aim is to demonstrate an evaluation method, including execution plans, to assess the pros and cons of each platform in relation to relevant application patterns, rather than specific use cases.
MongoDB
I create a simple collection with one field "nnn", indexed, and insert random values between 0 and 100:
db.franck.drop();
db.franck.createIndex({nnn:1});
void db.franck.insertMany(
Array.from({ length: 100000 }, () => (
{ nnn: (Math.random() * 100)}
))
);
I query values between 20 and 80, displaying the first five for pagination:
db.franck.find(
{ nnn: { $gte: 20, $lt: 80 } }
).sort({ nnn: 1 }).limit(5)
[
{ _id: ObjectId('68b37f883c2e2550c0d51c0c'), nnn: 20.00087217392812 },
{ _id: ObjectId('68b37f883c2e2550c0d5dd3c'), nnn: 20.000927538131542 },
{ _id: ObjectId('68b37f883c2e2550c0d5f1e7'), nnn: 20.000979995906974 },
{ _id: ObjectId('68b37f883c2e2550c0d59dc4'), nnn: 20.001754428025208 },
{ _id: ObjectId('68b37f883c2e2550c0d66c4f'), nnn: 20.002357317589414 }
]
Here is the execution plan with execution statistics:
db.franck.find(
{ nnn: { $gte: 20, $lt: 80 } }
).sort({ nnn: 1 }).limit(5).explain("executionStats").executionStats
{
executionSuccess: true,
nReturned: 5,
executionTimeMillis: 0,
totalKeysExamined: 5,
totalDocsExamined: 5,
executionStages: {
isCached: false,
stage: 'LIMIT',
nReturned: 5,
executionTimeMillisEstimate: 0,
works: 6,
advanced: 5,
needTime: 0,
needYield: 0,
saveState: 0,
restoreState: 0,
isEOF: 1,
limitAmount: 5,
inputStage: {
stage: 'FETCH',
nReturned: 5,
executionTimeMillisEstimate: 0,
works: 5,
advanced: 5,
needTime: 0,
needYield: 0,
saveState: 0,
restoreState: 0,
isEOF: 0,
docsExamined: 5,
alreadyHasObj: 0,
inputStage: {
stage: 'IXSCAN',
nReturned: 5,
executionTimeMillisEstimate: 0,
works: 5,
advanced: 5,
needTime: 0,
needYield: 0,
saveState: 0,
restoreState: 0,
isEOF: 0,
keyPattern: { nnn: 1 },
indexName: 'nnn_1',
isMultiKey: false,
multiKeyPaths: { nnn: [] },
isUnique: false,
isSparse: false,
isPartial: false,
indexVersion: 2,
direction: 'forward',
indexBounds: { nnn: [ '[20, 80)' ] },
keysExamined: 5,
seeks: 1,
dupsTested: 0,
dupsDropped: 0
}
}
}
}
MongoDB scanned the index (stage: 'IXSCAN') on "nnn" (keyPattern: { nnn: 1 }) for values between 20 and 80 (indexBounds: { nnn: [ '[20, 80)' ]). It examined 5 index entries (keysExamined: 5) and fetched the corresponding documents, resulting in 5 documents read (docsExamined: 5). It stopped (LIMIT) after returning the documents for the result (nReturned: 5).
We achieved exactly what we needed without any unnecessary work, so no further tuning is required. We could go further, like with a covering index to avoid the FETCH stage, but it's not needed as the number of documents fetched is low and bounded.
PostgreSQL with DocumentDB
To gain a comprehensive understanding of the emulation, I examine both the execution plan from the emulation and the execution plan in the underlying database. I begin by starting a container with DocumentDB.
I start a container for my lab using the DocumentDB image from the Microsoft repo, which will later move to the Linux Foundation, and I use the default ports.
docker run -d -p 10260:10260 -p 9712:9712 --name pgddb \
ghcr.io/microsoft/documentdb/documentdb-local:latest \
--username ddb --password ddb
I add auto-explain to show all execution plans for my lab:
## add auto-explain extension to be loaded
docker exec -it pgddb sed -e '/shared_preload_libraries/s/,/, auto_explain,/' -i /home/documentdb/postgresql/data/postgresql.conf
## bounce the instance
docker restart -t 5 pgddb
# set auto-explain by default for the emulation gateway
psql -e 'postgres://documentdb@localhost:9712/postgres' <<'SQL'
\dconfig shared_preload_libraries
alter user ddb set auto_explain.log_analyze=on;
alter user ddb set auto_explain.log_buffers=on;
alter user ddb set auto_explain.log_format=text;
alter user ddb set auto_explain.log_min_duration=0;
alter user ddb set auto_explain.log_nested_statements=on;
alter user ddb set auto_explain.log_settings=on;
alter user ddb set auto_explain.log_timing=on;
alter user ddb set auto_explain.log_triggers=on;
alter user ddb set auto_explain.log_verbose=on;
alter user ddb set auto_explain.log_wal=on;
SQL
# tail the PostgreSQL log in the background to see the execution plan
docker exec -it pgddb tail -f /home/documentdb/postgresql/data/pglog.log | grep -v " LOG: cron job" &
# connect to the MogoDB emulation gateway
mongosh 'mongodb://ddb:ddb@localhost:10260/?tls=true&tlsAllowInvalidCertificates=true'
I create the same collection and index as in my MongoDB test, run the same query, and check the execution plan:
db.franck.find(
{ nnn: { $gte: 20, $lt: 80 } }
).sort({ nnn: 1 }).limit(5).explain("executionStats").executionStats
{
nReturned: Long('5'),
executionTimeMillis: Long('154'),
totalDocsExamined: Long('5'),
totalKeysExamined: Long('5'),
executionStages: {
stage: 'LIMIT',
nReturned: Long('5'),
executionTimeMillis: Long('154'),
totalKeysExamined: Long('5'),
totalDocsExamined: 5,
numBlocksFromCache: 4415,
numBlocksFromDisk: 0,
inputStage: {
stage: 'SORT',
nReturned: Long('5'),
executionTimeMillis: Long('154'),
totalKeysExamined: Long('5'),
totalDocsExamined: 5,
sortMethod: 'top-N heapsort',
totalDataSizeSortedBytesEstimate: 26,
numBlocksFromCache: 4415,
numBlocksFromDisk: 0,
inputStage: {
stage: 'FETCH',
nReturned: Long('59935'),
executionTimeMillis: Long('133'),
totalKeysExamined: Long('59935'),
indexName: 'nnn_1',
totalDocsRemovedByIndexRechecks: 0,
numBlocksFromCache: 4415,
numBlocksFromDisk: 0,
inputStage: {
stage: 'IXSCAN',
nReturned: Long('59935'),
executionTimeMillis: Long('133'),
totalKeysExamined: Long('59935'),
indexName: 'nnn_1',
totalDocsRemovedByIndexRechecks: 0,
numBlocksFromCache: 4415,
numBlocksFromDisk: 0
}
}
}
}
}
DocumentDB scanned the index (stage: 'IXSCAN'), identifying the index name but lacking details on key patterns or index bounds. It appears to have read the correct range (from 20 to 80) but did not apply pagination efficiently, as evidenced by the high volume of index entries read (totalKeysExamined: Long('59935'), nReturned: Long('59935')). All documents were retrieved (stage: 'FETCH', nReturned: Long('59935')) and sorted for pagination (stage: 'SORT', sortMethod: 'top-N heapsort'). Ultimately, this process returned the final result of 5 documents (stage: 'LIMIT', nReturned: Long('5')), discarding the thousands of documents read.
While the query and result are similar to MongoDB, the execution differs significantly. MongoDB avoids reading all documents and sorting them because its index not only helps find a range but also returns results in order.
To grasp the underlying reasons for this difference, we need more than just the execution plan of the emulation. I installed auto-explain in my lab to analyze the execution plan in PostgreSQL:
2025-08-31 17:20:47.765 UTC [416] LOG: duration: 160.621 ms plan:
Query Text: EXPLAIN (FORMAT JSON, ANALYZE True, VERBOSE True, BUFFERS True, TIMING True) SELECT document FROM documentdb_api_catalog.bson_aggregation_find($1, $2)
Query Parameters: $1 = 'test', $2 = '\x5f0000000266696e6400070000006672616e636b000366696c7465720022000000036e6e6e00180000001024677465001400000010246c740050000000000003736f7274000e000000106e6e6e000100000000106c696d6974000500000000'
Limit (cost=517.87..517.88 rows=5 width=68) (actual time=160.617..160.618 rows=5 loops=1)
Output: document, (bson_orderby(document, 'BSONHEX0e000000106e6e6e000100000000'::documentdb_core.bson))
Buffers: shared hit=4447, temp read=1860 written=2002
-> Sort (cost=517.87..580.37 rows=25000 width=68) (actual time=160.615..160.616 rows=5 loops=1)
Output: document, (bson_orderby(document, 'BSONHEX0e000000106e6e6e000100000000'::documentdb_core.bson))
Sort Key: (bson_orderby(collection.document, 'BSONHEX0e000000106e6e6e000100000000'::documentdb_core.bson)) NULLS FIRST
Sort Method: top-N heapsort Memory: 26kB
Buffers: shared hit=4447, temp read=1860 written=2002
-> Index Scan using nnn_1 on documentdb_data.documents_2 collection (cost=0.00..102.62 rows=25000 width=68) (actual time=98.698..138.723 rows=59973 loops=1)
Output: document, bson_orderby(document, 'BSONHEX0e000000106e6e6e000100000000'::documentdb_core.bson)
Index Cond: (collection.document @<> 'BSONHEX3f000000036e6e6e0035000000106d696e0014000000106d61780050000000086d696e496e636c75736976650001086d6178496e636c757369766500000000'::documentdb_core.bson)
Buffers: shared hit=4439, temp read=1860 written=2002
Settings: search_path = 'documentdb_api_catalog, documentdb_api, public'
The steps are similar but more detailed at the PostgreSQL level. The index access utilizes the @<> operator, which checks if a BSON value is within a specified range via a RUM index (DocumentDB uses an extended version of RUM index which provides more metadata than a GIN index). In this case, the index was applied solely for filtering, while a separate Sort step managed the final ordering. This method requires reading all documents before they can be ordered. Although the example is simple, indexed fields may contain arrays, which means a forward scan must return entries in order based on the smallest in the array. This behavior is native to MongoDB but not in PostgreSQL, and it likely explains why entries can't be retrieved in the desired order in the current version of DocumentDB.
Note that you might see a Bitmap Scan before auto-vacuum runs, but it's important to recognize that an Index Scan is also possible, which is a key distinction from GIN indexes.
The definition of the table and index is visible from PostgreSQL:
postgres=# \d documentdb_data.documents_2
Table "documentdb_data.documents_2"
Column | Type | Collation | Nullable | Default
-----------------+--------------------------+-----------+----------+---------
shard_key_value | bigint | | not null |
object_id | documentdb_core.bson | | not null |
document | documentdb_core.bson | | not null |
creation_time | timestamp with time zone | | |
Indexes:
"collection_pk_2" PRIMARY KEY, btree (shard_key_value, object_id)
"documents_rum_index_3" documentdb_rum (document documentdb_api_catalog.bson_rum_single_path_ops (path=nnn, tl='2699'))
Check constraints:
"shard_key_value_check" CHECK (shard_key_value = '2'::bigint)
DocumentDB uses Citus for sharding, and an extended version of RUM indexes documentdb_rum for indexes:
postgres=# select indexdef, tablename, indexname
from pg_indexes
where schemaname='documentdb_data'
;
indexdef | tablename | indexname
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------+-----------------------
CREATE UNIQUE INDEX collection_pk_1 ON documentdb_data.documents_1 USING btree (shard_key_value, object_id) | documents_1 | collection_pk_1
CREATE UNIQUE INDEX retry_1_pkey ON documentdb_data.retry_1 USING btree (shard_key_value, transaction_id) | retry_1 | retry_1_pkey
CREATE INDEX retry_1_object_id_idx ON documentdb_data.retry_1 USING btree (object_id) | retry_1 | retry_1_object_id_idx
CREATE UNIQUE INDEX collection_pk_2 ON documentdb_data.documents_2 USING btree (shard_key_value, object_id) | documents_2 | collection_pk_2
CREATE UNIQUE INDEX retry_2_pkey ON documentdb_data.retry_2 USING btree (shard_key_value, transaction_id) | retry_2 | retry_2_pkey
CREATE INDEX retry_2_object_id_idx ON documentdb_data.retry_2 USING btree (object_id) | retry_2 | retry_2_object_id_idx
CREATE INDEX documents_rum_index_3 ON documentdb_data.documents_2 USING documentdb_rum (document documentdb_api_catalog.bson_rum_single_path_ops (path=nnn, tl='2699')) | documents_2 | documents_rum_index_3
(7 rows)
Here is the list of extensions installed:
postgres=# \dx
List of installed extensions
Name | Version | Schema | Description
-----------------+---------+------------+------------------------------------------------------------
documentdb | 0.104-0 | public | API surface for DocumentDB for PostgreSQL
documentdb_core | 0.104-0 | public | Core API surface for DocumentDB on PostgreSQL
pg_cron | 1.6 | pg_catalog | Job scheduler for PostgreSQL
plpgsql | 1.0 | pg_catalog | PL/pgSQL procedural language
postgis | 3.5.3 | public | PostGIS geometry and geography spatial types and functions
rum | 1.3 | public | RUM index access method
tsm_system_rows | 1.0 | public | TABLESAMPLE method which accepts number of rows as a limit
vector | 0.8.0 | public | vector data type and ivfflat and hnsw access methods
(8 rows)
In my example, DocumentDB performed more work than MongoDB because the sort operation was not pushed down to the index scan. Achieving ordered results from a multi-key index is challenging due to multiple index entries per document. The scan must deduplicate these entries and arrange them correctly: the lowest array value for a forward scan and the greatest for a backward scan. MongoDB implemented this functionality from the get-go in its multi-key indexes. Emulation on top of SQL databases still requires further development to match the performance and scalability, as RDBMS were not designed for multi-key indexes, as one-to-many relationships are typically managed in separate tables according to the first normal form. This remains a TODO in the code for DocumentDB's RUM index access method.
The order by pushdown is not the only limitation. If you cannot read the five index entries needed for the query, you should at least try to avoid fetching thousands of documents. In MongoDB, using a covering index will replace the FETCH stage with a PROJECTION_COVERED stage. I attempted the same in DocumentDB by omitting the "_id" from the projection:
db.franck.find(
{ nnn: { $gte: 20, $lt: 80 } },
{ _id: 0, nnn: 1 }
).sort({ nnn: 1 }).limit(5).hint({nnn:1, _id:1 }).explain("executionStats").executionStats
{
nReturned: Long('5'),
executionTimeMillis: Long('170'),
totalDocsExamined: Long('5'),
totalKeysExamined: Long('5'),
executionStages: {
stage: 'LIMIT',
nReturned: Long('5'),
executionTimeMillis: Long('170'),
totalKeysExamined: Long('5'),
totalDocsExamined: 5,
numBlocksFromCache: 4607,
numBlocksFromDisk: 0,
inputStage: {
stage: 'PROJECT',
nReturned: Long('5'),
executionTimeMillis: Long('170'),
totalKeysExamined: Long('5'),
totalDocsExamined: 5,
numBlocksFromCache: 4607,
numBlocksFromDisk: 0,
inputStage: {
stage: 'SORT',
nReturned: Long('5'),
executionTimeMillis: Long('170'),
totalKeysExamined: Long('5'),
totalDocsExamined: 5,
sortMethod: 'top-N heapsort',
totalDataSizeSortedBytesEstimate: 25,
numBlocksFromCache: 4607,
by Franck Pachot
August 31, 2025
Avinash Sajjanshetty
Why do we use caches at all? Can databases fully replace them?