DuckDB is a high-performance analytical database, but it is single-process, so it cannot replace an operational database. MongoDB is a general-purpose database for operational data in a flexible format. DuckDB lacks a native connector to MongoDB, but you can combine pg_duckdb and mongo_fdw, two PostgreSQL extensions, to query MongoDB with DuckDB.
A PostgreSQL Docker container with DuckDB and Mongo_FDW
Here is an example. I built a pg_duckdb image, With is PostgreSQL with DuckDB as an extension, where I install mongo_fdw, the Foreign Data Wrapper to access MongoDB from PostgreSQL. Here is my dockerfile:
ARG pg_version=17
# build stage to compole the MongoDB Foreign Data Wrapper
FROM pgduckdb/pgduckdb:${pg_version}-main as build
ARG pg_version
ARG MONGO_FDW_VERSION=5_5_2
ARG MONGO_FDW_URL=https://github.com/EnterpriseDB/mongo_fdw/archive/REL-${MONGO_FDW_VERSION}.tar.gz
ARG SOURCE_FILES=/tmp/mongo_fdw
ENV PKG_CONFIG_PATH=${SOURCE_FILES}/mongo-c-driver/src/libmongoc/src:${SOURCE_FILES}/mongo-c-driver/src/libbson/src
ENV LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu
ENV MONGOC_INSTALL_DIR=${LD_LIBRARY_PATH}
ENV JSONC_INSTALL_DIR=${LD_LIBRARY_PATH}
USER root
# dependencies (compilation and runtime)
RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates make gcc cmake pkg-config postgresql-server-dev-${pg_version} libssl-dev libzstd-dev libmongoc-dev libjson-c-dev libsnappy1v5 libmongocrypt0
# compile mongo_fdw
WORKDIR /var/tmp
ADD ${MONGO_FDW_URL} /var/tmp
WORKDIR /var/tmp
RUN tar -zxvf $(basename ${MONGO_FDW_URL})
# install monfo_fdw
WORKDIR /var/tmp/mongo_fdw-REL-${MONGO_FDW_VERSION}
RUN ./autogen.sh && make USE_PGXS=1 && make USE_PGXS=1 install;
# add mongosh because it can be useful
WORKDIR /var/tmp
ADD https://downloads.mongodb.com/compass/mongosh-2.5.2-linux-x64.tgz /tmp
RUN tar -xvf /tmp/mongosh-2.5.2-linux-x64.tgz
RUN cp ./mongosh-2.5.2-linux-x64/bin/mongosh /usr/local/bin
# final stage to add mongo_fdw to pgduckdb
FROM pgduckdb/pgduckdb:${pg_version}-main
USER root
ARG pg_version
ARG extdir=/usr/share/postgresql/${pg_version}/extension
ARG extlibdir=/usr/lib/postgresql/${pg_version}/lib
ARG libdir=/usr/lib/x86_64-linux-gnu
COPY --from=build ${extdir}/mongo_fdw* ${extdir}/
COPY --from=build ${extlibdir}/mongo_fdw.so ${extlibdir}/
COPY --from=build ${libdir}/libmongoc-1.0.so.0.0.0 \
${libdir}/libbson-1.0.so.0.0.0 \
${libdir}/libmongocrypt.so.0.0.0 \
${libdir}/libsnappy.so.1.1.9 \
${libdir}/
RUN cd ${libdir} && \
ln -sf libmongoc-1.0.so.0.0.0 libmongoc-1.0.so.0 && \
ln -sf libmongoc-1.0.so.0 libmongoc-1.0.so && \
ln -sf libbson-1.0.so.0.0.0 libbson-1.0.so.0 && \
ln -sf libbson-1.0.so.0 libbson-1.0.so && \
ln -sf libmongocrypt.so.0.0.0 libmongocrypt.so.0 && \
ln -sf libmongocrypt.so.0 libmongocrypt.so && \
ln -sf libsnappy.so.1.1.9 libsnappy.so.1 && \
ln -sf libsnappy.so.1 libsnappy.so;
COPY --from=build /usr/local/bin /usr/local/bin
USER postgres
Start the databases
I started a MongoDB container:
docker run --name mongodb $e -d mongodb/mongodb-community-server:latest
I built the image and started a container from my image, with network link to mongodb:
docker build -t duckduckmongo .
docker run --name duckduckmongo -d --link mongodb:mongodb -e POSTGRES_PASSWORD=postgres4mongo duckduckmongo
Create MongoDB collections
In this container, where I've installed the MongoDB Shell, I connected to test the connection to the MongoDB database:
docker exec -it duckduckmongo mongosh mongodb:27017/test
While there, I created a user:
db.createUser( {
user: "duckduckmongo",
pwd: "mongo4postgres",
roles: [ { role: "readWrite", db: "test" } ]
} );
I created two collections with some data:
db.dim.insertMany( Array.from({ length: 42 }, (_, i) => (
{
dimid: i + 1,
dimvalue: Math.random(),
tags: [ "fr" , "de" , "it" ],
coord: { x:1, y:-11 }
})) );
db.fact.insertMany( Array.from({ length: 100000 }, () => (
{
ref: Math.ceil(Math.random() * 42),
value: Math.random(),
}
)) );
From the field names, you can guess that I wanted to test some joins, with one collection referencing the other.
Create the Foreign Tables
I connected to PostgreSQL:
docker exec -it duckduckmongo psql -U postgres
I enabled the extensions:
create extension if not exists mongo_fdw;
create extension if not exists pg_duckdb;
I declared the Foreign Data Wrapper to connect to MongoDB:
CREATE SERVER "MongoDB server" FOREIGN DATA WRAPPER mongo_fdw
OPTIONS ( address 'mongodb', port '27017' )
;
GRANT USAGE ON FOREIGN SERVER "MongoDB server" TO postgres;
CREATE USER MAPPING FOR postgres SERVER "MongoDB server"
OPTIONS ( username 'duckduckmongo', password 'mongo4postgres' )
;
I declared the foreign tables that map to the MongoDB collections:
-- Note: first column of the table must be "_id" of type "name" according to the doc
CREATE FOREIGN TABLE dim (
_id name,
dimid int,
dimvalue float,
"coord.x" int, -- mapping nested object fields to column
"coord.y" int,
tags text[] -- mappint arrays as arrays
) SERVER "MongoDB server"
OPTIONS ( database 'test', collection 'dim' )
;
CREATE FOREIGN TABLE fact (
_id name,
ref int,
value float
) SERVER "MongoDB server"
OPTIONS ( database 'test', collection 'fact' )
;
Those foreign tables are like views and can be queried from PostgreSQL, and the Foreign Data Wrapper will query the MongoDB database server.
Query MongoDB from PostgreSQL with join
I run the following query, with a filter and a join:
select * from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
It takes several second and I can check the execution plan:
explain (analyze, costs off, verbose)
select * from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Foreign Scan (actual time=16.379..7545.432 rows=49889 loops=1)
Output: fact._id, fact.ref, fact.value, dim._id, dim.dimid, dim.dimvalue, dim."coord.x", dim."coord.y", dim.tags
Foreign Namespace: (test.fact) INNER JOIN (test.dim)
Planning Time: 2.226 ms
Execution Time: 7548.817 ms
The slowness occurs because the join in MongoDB uses a $lookup within an aggregation pipeline, which isn't optimized for fifty thousand documents.
In MongoDB, the $lookup operation can be slower than joins in an RDBMS due to its document model, which is designed for embedding related data and reducing the need for joins. The flexible schema allows fields to be scalars, arrays, or nested documents, making $lookup operations more complex than traditional equi-joins. A lookup is a key-intersection join, which requires implicit array traversal and matching across two sets of keys, resembling a semijoin but including all matching elements from the inner collection, typically as an array that can also be unwound to multiple documents.
When querying thousands of documents, it's more effective to run two separate queries instead of one $lookup. This can be done transparently through the Foreign Data Wrapper, which can avoid pushing down the join. A hash join is preferable for my query as it requires most rows from the "dim' collection. Note that MongoDB can also use a hash join for lookups but it is only effective if the table has fewer than ten thousand documents and temporary disk usage is allowed.
Query MongoDB collection but join in PostgreSQL
To execute the join in PostgreSQL, I disabled the join pushdown:
set mongo_fdw.enable_join_pushdown to false;
explain (analyze, costs off, verbose)
select * from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Hash Join (actual time=1.524..102.797 rows=49889 loops=1)
Output: fact._id, fact.ref, fact.value, dim._id, dim.dimid, dim.dimvalue, dim."coord.x", dim."coord.y", dim.tags
Hash Cond: (fact.ref = dim.dimid)
-> Foreign Scan on public.fact (actual time=0.923..90.954 rows=49889 loops=1)
Output: fact._id, fact.ref, fact.value
Foreign Namespace: test.fact
-> Hash (actual time=0.575..0.575 rows=42 loops=1)
Output: dim._id, dim.dimid, dim.dimvalue, dim."coord.x", dim."coord.y", dim.tags
Buckets: 1024 Batches: 1 Memory Usage: 15kB
-> Foreign Scan on public.dim (actual time=0.488..0.552 rows=42 loops=1)
Output: dim._id, dim.dimid, dim.dimvalue, dim."coord.x", dim."coord.y", dim.tags
Foreign Namespace: test.dim
Planning Time: 2.341 ms
Execution Time: 105.252 ms
This has pushed the filter to MongoDB but not the join, so it has read all documents from "dim" in 0.6 milliseconds, to build the hashed table, and fifty thousand documents from "fact" in 92 milliseconds, serving as the probe table for the hash join.
I disabled join pushdown for my session, but it can be an option of the foreign table declaration.
Query planner cardinalities
When not pushing down the joins, it is important to get good cardinality estimations because it uses the PostgreSQL cost-based optimizer. By default the MongoDB Foreign Data Wrapper uses the same constant for all tables:
explain
select * from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
QUERY PLAN
----------------------------------------------------------------------------
Hash Join (cost=1062.50..2240.00 rows=5000 width=192)
Hash Cond: (fact.ref = dim.dimid)
-> Foreign Scan on fact (cost=25.00..1025.00 rows=1000 width=76)
Foreign Namespace: test.fact
-> Hash (cost=1025.00..1025.00 rows=1000 width=116)
-> Foreign Scan on dim (cost=25.00..1025.00 rows=1000 width=116)
Foreign Namespace: test.dim
I was fortunate to have the small table hashed, but it is not a guarantee, as both options have the same cost. To get more accurate cardinality estimations, I enabled the remote estimate feature, which requests a count estimation from the MongoDB database:
ALTER SERVER "MongoDB server"
OPTIONS (ADD use_remote_estimate 'true')
;
explain
select * from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
QUERY PLAN
----------------------------------------------------------------------
Hash Join (cost=14.04..3887.03 rows=7000 width=192)
Hash Cond: (fact.ref = dim.dimid)
-> Foreign Scan on fact (cost=5.00..3682.98 rows=33333 width=76)
Foreign Namespace: test.fact
-> Hash (cost=8.52..8.52 rows=42 width=116)
-> Foreign Scan on dim (cost=5.00..8.52 rows=42 width=116)
Foreign Namespace: test.dim
To determine the correct join order, even an estimated selectivity is sufficient. In a hash join, the smaller table should be the build table, while the larger one serves as the probe table.
Invoke DuckDB to query the foreign tables
This didn't go through DuckDB because DuckDB, here, is an extension within PostgreSQL. To run the same though DuckDB I force it:
set duckdb.force_execution to true;
explain (analyze, costs off, verbose)
select * from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
WARNING: Unsupported Postgres type: No conversion to DuckDB available for type with oid=19)
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Hash Join (actual time=1.598..102.117 rows=49889 loops=1)
Output: fact._id, fact.ref, fact.value, dim._id, dim.dimid, dim.dimvalue, dim."coord.x", dim."coord.y", dim.tags
Hash Cond: (fact.ref = dim.dimid)
-> Foreign Scan on public.fact (actual time=1.018..90.344 rows=49889 loops=1)
Output: fact._id, fact.ref, fact.value
Foreign Namespace: test.fact
-> Hash (actual time=0.558..0.558 rows=42 loops=1)
Output: dim._id, dim.dimid, dim.dimvalue, dim."coord.x", dim."coord.y", dim.tags
Buckets: 1024 Batches: 1 Memory Usage: 15kB
-> Foreign Scan on public.dim (actual time=0.464..0.527 rows=42 loops=1)
Output: dim._id, dim.dimid, dim.dimvalue, dim."coord.x", dim."coord.y", dim.tags
Foreign Namespace: test.dim
Planning Time: 25.667 ms
Execution Time: 104.390 ms
The execution looks the same because it has actually been run by PostgreSQL - it is a PostgreSQL execution plan. Note that I got the following warning:
WARNING: Unsupported Postgres type: No conversion to DuckDB available for type with oid=19)
To analyze the query prepared by the DuckDB extension, I enabled DuckDB debug messages:
set client_min_messages to debug;
set duckdb.log_pg_explain to true;
explain (analyze, costs off, verbose)
select * from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
DEBUG: (PGDuckDB/DuckdbPrepare) Preparing:
SELECT fact._id, fact.ref, fact.value, dim._id, dim.dimid, dim.dimvalue , dim."coord.x", dim."coord.y", dim.tags
FROM (pgduckdb.public.fact
JOIN pgduckdb.public.dim ON ((fact.ref = dim.dimid)))
WHERE (fact.value > (0.5)::double precision)
DEBUG: (DuckDB/SetTableInfo) Column name: '_id', Type: "No conversion to DuckDB available for type with oid=19)"
DEBUG: (DuckDB/SetTableInfo) Column name: 'ref', Type: INTEGER
DEBUG: (DuckDB/SetTableInfo) Column name: 'value', Type: DOUBLE
DEBUG: (DuckDB/SetTableInfo) Column name: '_id', Type: "No conversion to DuckDB available for type with oid=19)"
DEBUG: (DuckDB/SetTableInfo) Column name: 'dimid', Type: INTEGER
DEBUG: (DuckDB/SetTableInfo) Column name: 'dimvalue', Type: DOUBLE
DEBUG: (DuckDB/SetTableInfo) Column name: 'coord.x', Type: INTEGER
DEBUG: (DuckDB/SetTableInfo) Column name: 'coord.y', Type: INTEGER
DEBUG: (DuckDB/SetTableInfo) Column name: 'tags', Type: VARCHAR[]
I ran the same without the "_id" columns which I don't need and get a DuckDB execution plan:
explain (analyze, costs off, verbose)
select fact.ref, fact.value, dim.dimid, dim.dimvalue, dim.dimvalue
, dim."coord.x", dim."coord.y", dim.tags
from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Custom Scan (DuckDBScan) (actual time=0.001..0.002 rows=0 loops=1)
Output: duckdb_scan.ref, duckdb_scan.value, duckdb_scan.dimid, duckdb_scan.dimvalue, duckdb_scan.dimvalue_1, duckdb_scan."coord.x", duckdb_scan."coord.y", duckdb_scan.tags
DuckDB Execution Plan:
βββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββ
ββ Query Profiling Information ββ
βββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββ
EXPLAIN ANALYZE SELECT fact.ref, fact.value, dim.dimid, dim.dimvalue, dim.dimvalue, dim.
by Franck Pachot
Franck Pachot
DuckDB is a high-performance analytical database, but it is single-process, so it cannot replace an operational database. MongoDB is a general-purpose database for operational data in a flexible format. DuckDB lacks a native connector to MongoDB, but you can combine pg_duckdb and mongo_fdw, two PostgreSQL extensions, to query MongoDB with DuckDB.
- A PostgreSQL Docker container with DuckDB and Mongo_FDW
- Start the databases
- Create MongoDB collections
- Create the Foreign Tables
- Query MongoDB from PostgreSQL with join
- Query MongoDB collection but join in PostgreSQL
- Query planner cardinalities
- Invoke DuckDB to query the foreign tables
- Cache static tables as temporary tables
- Map the whole document
A PostgreSQL Docker container with DuckDB and Mongo_FDW
Here is an example. I built a pg_duckdb image, With is PostgreSQL with DuckDB as an extension, where I install mongo_fdw, the Foreign Data Wrapper to access MongoDB from PostgreSQL. Here is my dockerfile:
ARG pg_version=17
# build stage to compole the MongoDB Foreign Data Wrapper
FROM pgduckdb/pgduckdb:${pg_version}-main as build
ARG pg_version
ARG MONGO_FDW_VERSION=5_5_2
ARG MONGO_FDW_URL=https://github.com/EnterpriseDB/mongo_fdw/archive/REL-${MONGO_FDW_VERSION}.tar.gz
ARG SOURCE_FILES=/tmp/mongo_fdw
ENV PKG_CONFIG_PATH=${SOURCE_FILES}/mongo-c-driver/src/libmongoc/src:${SOURCE_FILES}/mongo-c-driver/src/libbson/src
ENV LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu
ENV MONGOC_INSTALL_DIR=${LD_LIBRARY_PATH}
ENV JSONC_INSTALL_DIR=${LD_LIBRARY_PATH}
USER root
# dependencies (compilation and runtime)
RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates make gcc cmake pkg-config postgresql-server-dev-${pg_version} libssl-dev libzstd-dev libmongoc-dev libjson-c-dev libsnappy1v5 libmongocrypt0
# compile mongo_fdw
WORKDIR /var/tmp
ADD ${MONGO_FDW_URL} /var/tmp
WORKDIR /var/tmp
RUN tar -zxvf $(basename ${MONGO_FDW_URL})
# install monfo_fdw
WORKDIR /var/tmp/mongo_fdw-REL-${MONGO_FDW_VERSION}
RUN ./autogen.sh && make USE_PGXS=1 && make USE_PGXS=1 install;
# add mongosh because it can be useful
WORKDIR /var/tmp
ADD https://downloads.mongodb.com/compass/mongosh-2.5.2-linux-x64.tgz /tmp
RUN tar -xvf /tmp/mongosh-2.5.2-linux-x64.tgz
RUN cp ./mongosh-2.5.2-linux-x64/bin/mongosh /usr/local/bin
# final stage to add mongo_fdw to pgduckdb
FROM pgduckdb/pgduckdb:${pg_version}-main
USER root
ARG pg_version
ARG extdir=/usr/share/postgresql/${pg_version}/extension
ARG extlibdir=/usr/lib/postgresql/${pg_version}/lib
ARG libdir=/usr/lib/x86_64-linux-gnu
COPY --from=build ${extdir}/mongo_fdw* ${extdir}/
COPY --from=build ${extlibdir}/mongo_fdw.so ${extlibdir}/
COPY --from=build ${libdir}/libmongoc-1.0.so.0.0.0 \
${libdir}/libbson-1.0.so.0.0.0 \
${libdir}/libmongocrypt.so.0.0.0 \
${libdir}/libsnappy.so.1.1.9 \
${libdir}/
RUN cd ${libdir} && \
ln -sf libmongoc-1.0.so.0.0.0 libmongoc-1.0.so.0 && \
ln -sf libmongoc-1.0.so.0 libmongoc-1.0.so && \
ln -sf libbson-1.0.so.0.0.0 libbson-1.0.so.0 && \
ln -sf libbson-1.0.so.0 libbson-1.0.so && \
ln -sf libmongocrypt.so.0.0.0 libmongocrypt.so.0 && \
ln -sf libmongocrypt.so.0 libmongocrypt.so && \
ln -sf libsnappy.so.1.1.9 libsnappy.so.1 && \
ln -sf libsnappy.so.1 libsnappy.so;
COPY --from=build /usr/local/bin /usr/local/bin
USER postgres
Start the databases
I started a MongoDB container:
docker run --name mongodb $e -d mongodb/mongodb-community-server:latest
I built the image and started a container from my image, with network link to mongodb:
docker build -t duckduckmongo .
docker run --name duckduckmongo -d --link mongodb:mongodb -e POSTGRES_PASSWORD=postgres4mongo duckduckmongo
Create MongoDB collections
In this container, where I've installed the MongoDB Shell, I connected to test the connection to the MongoDB database:
docker exec -it duckduckmongo mongosh mongodb:27017/test
While there, I created a user:
db.createUser( {
user: "duckduckmongo",
pwd: "mongo4postgres",
roles: [ { role: "readWrite", db: "test" } ]
} );
I created two collections with some data:
db.dim.insertMany( Array.from({ length: 42 }, (_, i) => (
{
dimid: i + 1,
dimvalue: Math.random(),
tags: [ "fr" , "de" , "it" ],
coord: { x:1, y:-11 }
})) );
db.fact.insertMany( Array.from({ length: 100000 }, () => (
{
ref: Math.ceil(Math.random() * 42),
value: Math.random(),
}
)) );
From the field names, you can guess that I wanted to test some joins, with one collection referencing the other.
Create the Foreign Tables
I connected to PostgreSQL:
docker exec -it duckduckmongo psql -U postgres
I enabled the extensions:
create extension if not exists mongo_fdw;
create extension if not exists pg_duckdb;
I declared the Foreign Data Wrapper to connect to MongoDB:
CREATE SERVER "MongoDB server" FOREIGN DATA WRAPPER mongo_fdw
OPTIONS ( address 'mongodb', port '27017' )
;
GRANT USAGE ON FOREIGN SERVER "MongoDB server" TO postgres;
CREATE USER MAPPING FOR postgres SERVER "MongoDB server"
OPTIONS ( username 'duckduckmongo', password 'mongo4postgres' )
;
I declared the foreign tables that map to the MongoDB collections:
-- Note: first column of the table must be "_id" of type "name" according to the doc
CREATE FOREIGN TABLE dim (
_id name,
dimid int,
dimvalue float,
"coord.x" int, -- mapping nested object fields to column
"coord.y" int,
tags text[] -- mappint arrays as arrays
) SERVER "MongoDB server"
OPTIONS ( database 'test', collection 'dim' )
;
CREATE FOREIGN TABLE fact (
_id name,
ref int,
value float
) SERVER "MongoDB server"
OPTIONS ( database 'test', collection 'fact' )
;
Those foreign tables are like views and can be queried from PostgreSQL, and the Foreign Data Wrapper will query the MongoDB database server.
Query MongoDB from PostgreSQL with join
I run the following query, with a filter and a join:
select * from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
It takes several second and I can check the execution plan:
explain (analyze, costs off, verbose)
select * from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Foreign Scan (actual time=16.379..7545.432 rows=49889 loops=1)
Output: fact._id, fact.ref, fact.value, dim._id, dim.dimid, dim.dimvalue, dim."coord.x", dim."coord.y", dim.tags
Foreign Namespace: (test.fact) INNER JOIN (test.dim)
Planning Time: 2.226 ms
Execution Time: 7548.817 ms
The slowness occurs because the join in MongoDB uses a $lookup within an aggregation pipeline, which isn't optimized for fifty thousand documents.
In MongoDB, the $lookup operation can be slower than joins in an RDBMS due to its document model, which is designed for embedding related data and reducing the need for joins. The flexible schema allows fields to be scalars, arrays, or nested documents, making $lookup operations more complex than traditional equi-joins. A lookup is a key-intersection join, which requires implicit array traversal and matching across two sets of keys, resembling a semijoin but including all matching elements from the inner collection, typically as an array that can also be unwound to multiple documents.
When querying thousands of documents, it's more effective to run two separate queries instead of one $lookup. This can be done transparently through the Foreign Data Wrapper, which can avoid pushing down the join. A hash join is preferable for my query as it requires most rows from the "dim' collection. Note that MongoDB can also use a hash join for lookups but it is only effective if the table has fewer than ten thousand documents and temporary disk usage is allowed.
Query MongoDB collection but join in PostgreSQL
To execute the join in PostgreSQL, I disabled the join pushdown:
set mongo_fdw.enable_join_pushdown to false;
explain (analyze, costs off, verbose)
select * from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Hash Join (actual time=1.524..102.797 rows=49889 loops=1)
Output: fact._id, fact.ref, fact.value, dim._id, dim.dimid, dim.dimvalue, dim."coord.x", dim."coord.y", dim.tags
Hash Cond: (fact.ref = dim.dimid)
-> Foreign Scan on public.fact (actual time=0.923..90.954 rows=49889 loops=1)
Output: fact._id, fact.ref, fact.value
Foreign Namespace: test.fact
-> Hash (actual time=0.575..0.575 rows=42 loops=1)
Output: dim._id, dim.dimid, dim.dimvalue, dim."coord.x", dim."coord.y", dim.tags
Buckets: 1024 Batches: 1 Memory Usage: 15kB
-> Foreign Scan on public.dim (actual time=0.488..0.552 rows=42 loops=1)
Output: dim._id, dim.dimid, dim.dimvalue, dim."coord.x", dim."coord.y", dim.tags
Foreign Namespace: test.dim
Planning Time: 2.341 ms
Execution Time: 105.252 ms
This has pushed the filter to MongoDB but not the join, so it has read all documents from "dim" in 0.6 milliseconds, to build the hashed table, and fifty thousand documents from "fact" in 92 milliseconds, serving as the probe table for the hash join.
I disabled join pushdown for my session, but it can be an option of the foreign table declaration.
Query planner cardinalities
When not pushing down the joins, it is important to get good cardinality estimations because it uses the PostgreSQL cost-based optimizer. By default the MongoDB Foreign Data Wrapper uses the same constant for all tables:
explain
select * from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
QUERY PLAN
----------------------------------------------------------------------------
Hash Join (cost=1062.50..2240.00 rows=5000 width=192)
Hash Cond: (fact.ref = dim.dimid)
-> Foreign Scan on fact (cost=25.00..1025.00 rows=1000 width=76)
Foreign Namespace: test.fact
-> Hash (cost=1025.00..1025.00 rows=1000 width=116)
-> Foreign Scan on dim (cost=25.00..1025.00 rows=1000 width=116)
Foreign Namespace: test.dim
I was fortunate to have the small table hashed, but it is not a guarantee, as both options have the same cost. To get more accurate cardinality estimations, I enabled the remote estimate feature, which requests a count estimation from the MongoDB database:
ALTER SERVER "MongoDB server"
OPTIONS (ADD use_remote_estimate 'true')
;
explain
select * from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
QUERY PLAN
----------------------------------------------------------------------
Hash Join (cost=14.04..3887.03 rows=7000 width=192)
Hash Cond: (fact.ref = dim.dimid)
-> Foreign Scan on fact (cost=5.00..3682.98 rows=33333 width=76)
Foreign Namespace: test.fact
-> Hash (cost=8.52..8.52 rows=42 width=116)
-> Foreign Scan on dim (cost=5.00..8.52 rows=42 width=116)
Foreign Namespace: test.dim
To determine the correct join order, even an estimated selectivity is sufficient. In a hash join, the smaller table should be the build table, while the larger one serves as the probe table.
Invoke DuckDB to query the foreign tables
This didn't go through DuckDB because DuckDB, here, is an extension within PostgreSQL. To run the same though DuckDB I force it:
set duckdb.force_execution to true;
explain (analyze, costs off, verbose)
select * from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
WARNING: Unsupported Postgres type: No conversion to DuckDB available for type with oid=19)
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Hash Join (actual time=1.598..102.117 rows=49889 loops=1)
Output: fact._id, fact.ref, fact.value, dim._id, dim.dimid, dim.dimvalue, dim."coord.x", dim."coord.y", dim.tags
Hash Cond: (fact.ref = dim.dimid)
-> Foreign Scan on public.fact (actual time=1.018..90.344 rows=49889 loops=1)
Output: fact._id, fact.ref, fact.value
Foreign Namespace: test.fact
-> Hash (actual time=0.558..0.558 rows=42 loops=1)
Output: dim._id, dim.dimid, dim.dimvalue, dim."coord.x", dim."coord.y", dim.tags
Buckets: 1024 Batches: 1 Memory Usage: 15kB
-> Foreign Scan on public.dim (actual time=0.464..0.527 rows=42 loops=1)
Output: dim._id, dim.dimid, dim.dimvalue, dim."coord.x", dim."coord.y", dim.tags
Foreign Namespace: test.dim
Planning Time: 25.667 ms
Execution Time: 104.390 ms
The execution looks the same because it has actually been run by PostgreSQL - it is a PostgreSQL execution plan. Note that I got the following warning:
WARNING: Unsupported Postgres type: No conversion to DuckDB available for type with oid=19)
To analyze the query prepared by the DuckDB extension, I enabled DuckDB debug messages:
set client_min_messages to debug;
set duckdb.log_pg_explain to true;
explain (analyze, costs off, verbose)
select * from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
DEBUG: (PGDuckDB/DuckdbPrepare) Preparing:
SELECT fact._id, fact.ref, fact.value, dim._id, dim.dimid, dim.dimvalue , dim."coord.x", dim."coord.y", dim.tags
FROM (pgduckdb.public.fact
JOIN pgduckdb.public.dim ON ((fact.ref = dim.dimid)))
WHERE (fact.value > (0.5)::double precision)
DEBUG: (DuckDB/SetTableInfo) Column name: '_id', Type: "No conversion to DuckDB available for type with oid=19)"
DEBUG: (DuckDB/SetTableInfo) Column name: 'ref', Type: INTEGER
DEBUG: (DuckDB/SetTableInfo) Column name: 'value', Type: DOUBLE
DEBUG: (DuckDB/SetTableInfo) Column name: '_id', Type: "No conversion to DuckDB available for type with oid=19)"
DEBUG: (DuckDB/SetTableInfo) Column name: 'dimid', Type: INTEGER
DEBUG: (DuckDB/SetTableInfo) Column name: 'dimvalue', Type: DOUBLE
DEBUG: (DuckDB/SetTableInfo) Column name: 'coord.x', Type: INTEGER
DEBUG: (DuckDB/SetTableInfo) Column name: 'coord.y', Type: INTEGER
DEBUG: (DuckDB/SetTableInfo) Column name: 'tags', Type: VARCHAR[]
I ran the same without the "_id" columns which I don't need and get a DuckDB execution plan:
explain (analyze, costs off, verbose)
select fact.ref, fact.value, dim.dimid, dim.dimvalue, dim.dimvalue
, dim."coord.x", dim."coord.y", dim.tags
from fact join dim on fact.ref=dim.dimid
where fact.value>0.5
;
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Custom Scan (DuckDBScan) (actual time=0.001..0.002 rows=0 loops=1)
Output: duckdb_scan.ref, duckdb_scan.value, duckdb_scan.dimid, duckdb_scan.dimvalue, duckdb_scan.dimvalue_1, duckdb_scan."coord.x", duckdb_scan."coord.y", duckdb_scan.tags
DuckDB Execution Plan:
βββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββ
ββ Query Profiling Information ββ
βββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββ
EXPLAIN ANALYZE SELECT fact.ref, fact
by Franck Pachot
Percona Database Performance Blog
As a member of the Kubernetes Certified Services Provider program, Percona is now part of a select, βpre-qualified tier of vetted service providers who have deep experience helping enterprises successfully adopt Kubernetesβ¦β Kubernetes (frequently abbreviated as K8s) has come a long way over the past decade. From being used almost exclusively for orchestrating stateless container […]
by Cindy Neely
June 11, 2025
Small Datum - Mark Callaghan
This is my second attempt at an IO-bound Insert Benchmark results with a small server. The first attempt is here and has been deprecated because sloppy programming by me meant the benchmark client was creating too many connections and that hurt results in some cases for Postgres 18 beta1.
There might be regressions from 17.5 to 18 beta1
- QPS decreases by ~5% and CPU increases by ~5% on the l.i2 (write-only) step
- QPS decreases by <= 2% and CPU increases by ~2% on the qr* (range query) steps
There might be regressions from 14.0 to 18 beta1
- QPS decreases by ~6% and ~18% on the write-heavy steps (l.i1, l.i2)
Builds, configuration and hardware
I compiled Postgres from source using -O2 -fno-omit-frame-pointer for versions 14.0, 14.18, 15.0, 15.13, 16.0, 16.9, 17.0, 17.5 and 18 beta1.
The server is an ASUS ExpertCenter PN53 with and AMD Ryzen 7 7735HS CPU, 8 cores, SMT disabled, 32G of RAM and one NVMe device for the database. The OS has been updated to Ubuntu 24.04. More details on it
are here.
For Postgres versions 14.0 through 17.5 the configuration files are in the pg*
subdirectories here with the name
conf.diff.cx10a_c8r32. For Postgres 18 beta1 I used 3 variations, which
are here:
- conf.diff.cx10b_c8r32
- uses io_method='sync' to match Postgres 17 behavior
- conf.diff.cx10c_c8r32
- uses io_method='worker' and io_workers=16 to do async IO via a thread pool. I eventually learned that 16 is too large.
- conf.diff.cx10d_c8r32
- uses io_method='io_uring' to do async IO via io_uring
The Benchmark
The benchmark is
explained here and is run with 1 client and 1 table with 800M rows. I provide two performance reports:
- one to compare Postgres 14.0 through 18 beta1, all using synchronous IO
- one to compare Postgres 17.5 with 18 beta1 using 3 configurations for 18 beta1 -- one for each of io_method= sync, workers and io_uring.
The benchmark steps are:
- l.i0
- insert 20 million rows per table in PK order. The table has a PK index but no secondary indexes. There is one connection per client.
- l.x
- create 3 secondary indexes per table. There is one connection per client.
- l.i1
- use 2 connections/client. One inserts 4M rows per table and the other does deletes at the same rate as the inserts. Each transaction modifies 50 rows (big transactions). This step is run for a fixed number of inserts, so the run time varies depending on the insert rate.
- l.i2
- like l.i1 but each transaction modifies 5 rows (small transactions) and 1M rows are inserted and deleted per table.
- Wait for X seconds after the step finishes to reduce variance during the read-write benchmark steps that follow. The value of X is a function of the table size.
- qr100
- use 3 connections/client. One does range queries and performance is reported for this. The second does does 100 inserts/s and the third does 100 deletes/s. The second and third are less busy than the first. The range queries use covering secondary indexes. This step is run for 1800 seconds. If the target insert rate is not sustained then that is considered to be an SLA failure. If the target insert rate is sustained then the step does the same number of inserts for all systems tested.
- qp100
- like qr100 except uses point queries on the PK index
- qr500
- like qr100 but the insert and delete rates are increased from 100/s to 500/s
- qp500
- like qp100 but the insert and delete rates are increased from 100/s to 500/s
- qr1000
- like qr100 but the insert and delete rates are increased from 100/s to 1000/s
- qp1000
- like qp100 but the insert and delete rates are increased from 100/s to 1000/s
The summary sections (
herehere and
here) have 3 tables. The first shows absolute throughput by DBMS tested X benchmark step. The second has throughput relative to the version from the first row of the table. The third shows the background insert rate for the benchmark steps. The second table makes it easy to see how performance changes over time. The third table makes it easy to see which DBMS+configs failed to meet the SLA.
Below I use relative QPS (rQPS) to explain how performance changes. It is: (QPS for $me / QPS for $base) where $me is the result for some version $base is the result from Postgres 17.5.
When rQPS is > 1.0 then performance improved over time. When it is < 1.0 then there are regressions. When it is 0.90 then I claim there is a 10% regression. The Q in relative QPS measures:
- insert/s for l.i0, l.i1, l.i2
- indexed rows/s for l.x
- range queries/s for qr100, qr500, qr1000
- point queries/s for qp100, qp500, qp1000
Below I use colors to highlight the relative QPS values with red for <= 0.97, green for >= 1.03 and grey for values between 0.98 and 1.02.
Results: Postgres 14.0 through 18 beta1
See the previous section for the definition of relative QPS (rQPS). For the rQPS formula, Postgres 14.0 is the base version and that is compared with more recent Postgres versions. The results here are similar to
what I reported prior to fixing the
too many connections problem in the benchmark client.
For 14.0 through 18 beta1, QPS on ...
- the initial load (l.i0)
- Performance is stable across versions
- 18 beta1 and 17.5 have similar performance
- rQPS for (17.5, 18 beta1 with io_method=sync) is (1.00, 0.99)
- create index (l.x)
- ~10% faster starting in 15.0
- 18 beta1 and 17.5 have similar performance
- rQPS for (17.5, 18 beta1 with io_method=sync) is (1.11, 1.12)
- first write-only step (l.i1)
- Performance decreases ~7% from version 16.9 to 17.0. CPU overhead (see cpupq here) increases by ~5% in 17.0.
- 18 beta1 and 17.5 have similar performance
- rQPS for (17.5, 18 beta1 with io_method=sync) is (0.93, 0.94)
- second write-only step (l.i2)
- Performance decreases ~6% in 15.0, ~8% in 17.0 and then ~5% in 18.0. CPU overhead (see cpupq here) increases ~5%, ~6% and ~5% in 15.0, 17.0 and 18beta1. Of all benchmark steps, this has the largest perf regression from 14.0 through 18 beta1 which is ~20%.
- 18 beta1 is ~4% slower than 17.5
- rQPS for (17.5, 18 beta1 with io_method=sync) is (0.86, 0.82)
- range query steps (qr100, qr500, qr1000)
- 18 beta1 and 17.5 have similar performance, but 18 beta1 is slightly slower
- rQPS for (17.5, 18 beta1 with io_method=sync) is (1.00, 0.99) for qr100, (0.97, 0.98) for qr500 and (0.97, 0.95) for qr1000. The issue is new CPU overhead, see cpupq here.
- point query steps (qp100, qp500, qp1000)
- 18 beta1 and 17.5 have similar performance but 18 beta1 is slightly slower
- rQPS for (17.5, 18 beta1 with io_method=sync) is (1.00, 0.98) for qp100, (0.99, 0.98) for qp500 and (0.97, 0.96) for qp1000. The issue is new CPU overhead, see cpupq here.
Results: Postgres 17.5 vs 18 beta1
See the previous section for the definition of relative QPS (rQPS). For the rQPS formula, Postgres 17.5 is the base version and that is compared with results from 18 beta1 using the three configurations explained above:
- x10b with io_method=sync
- x10c with io_method=worker and io_workers=16
- x10d with io_method=io_uring
The summary is:
- initial load step (l.i0)
- rQPS for (x10b, x10c, x10d) was (0.99, 100, 1.00)
- create index step (l.x)
- rQPS for (x10b, x10c, x10d) was (1.01, 1.02, 1.02)
- first write-heavy ste (l.i1)
- for l.i1 the rQPS for (x10b, x10c, x10d) was (1.00, 0.99, 1.01)
- second write-heavy step (l.i2)
- for l.i2 the rQPS for (x10b, x10c, x10d) was (0.96, 0.93, 0.94)
- CPU overhead (see cpupq here) increases by ~5% in 18 beta1
- range query steps (qr100, qr500, qr1000)
- for qr100 the rQPS for (x10b, x10c, x10d) was (1.00, 0.99, 0.99)
- for qr500 the rQPS for (x10b, x10c, x10d) was (1.00, 0.97, 0.99)
- for qr1000 the rQPS for (x10b, x10c, x10d) was (0.99, 0.98, 0.97)
- CPU overhead (see cpupq here, here and here) increases by ~2% in 18 beta1
- point query steps (qp100, qp500, qp1000)
- for qp100 the rQPS for (x10b, x10c, x10d) was (0.98, 0.99, 0.99)
- for qp500 the rQPS for (x10b, x10c, x10d) was (0.99, 1.00, 1.00)
- for qp1000 the rQPS for (x10b, x10c, x10d) was (0.99, 0.99, 0.99)
by Mark Callaghan (noreply@blogger.com)
June 10, 2025
Percona Database Performance Blog
Weβre proud to share that Percona has been named to the 2025 DBTA 100βDatabase Trends and Applicationsβ annual list of “The Companies That Matter Most in Data.” This recognition highlights our success in empowering organizations to build, scale, and optimize open source database environments for todayβs most demanding applications. At Percona, we believe open source […]
by Cindy Neely
Supabase Blog
Today we are welcoming Sugu, the co-creator of Vitess, to the Supabase team. He is joining Supabase to build Multigres: Vitess for Postgres.