🦆

カオスすぎる PostgreSQL × DuckDB 連携の現在地を探る

これは PostgreSQL Advent Calendar 2025 12月13日の記事です。

まえがき

エンジニアの吉田です。
フォルシアではPostgreSQLを分析寄りの用途で活用する機会が多いのですが、OLAP寄りの機能は3rd party拡張で補完されるものが多いため、しばしば拡張ジャンキーと化して情報収集や検証を行っています。

昨今では分析系DBの名前として真っ先に DuckDB の名前が挙がります。 DuckDBは組み込みデータベース、かつ様々なデータをソースとして読み込むことができるため、活用の可能性が非常に広いのが特徴です。
一方でPostgreSQLもまた、拡張開発の自由度が高く色々なことができます。色々なことができる者同士、PostgreSQLとDuckDBを連携させるという試みもここ数年で開発されつつありますが、その流れがあまりにも急速で、関連プロジェクトの乱立や頻繁なアップデートによりカオスな状況になっているように感じています。

そこで本記事では、2025年12月現在におけるPostgreSQL × DuckDB連携の有力な選択肢を紹介し、それぞれの用途や現在地についてまとめてみようと思います。

DuckDBからPostgreSQLに接続する

連携と一口に言っても、どちらからどちらに連携するのか、という所で手法が分かれます。DuckDBを主体としたとき、PostgreSQLの役割としてはデータソースの一つということになります。
つまりこの向きの連携は、DuckDBがPostgreSQLサーバーに接続するという形式になります。これは公式に用意されている拡張によって実現できます。
詳しい使用方法はリンク先に記載の通りですが、DuckDB上で接続したPostgreSQLのテーブルを扱うことができるようになります。

この拡張は、DuckDBのサポートする様々なフォーマットの1つにPostgreSQLを加えるもの、と解釈するのが良さそうです。
あくまでPostgreSQLサーバーにクエリを投げる機能であり、そういう意味ではPostgreSQLのfdwに近い機能といえます。

PostgreSQLの内部にDuckDBを組み込む

逆にPostgreSQLを主体としたとき、DuckDBは分析向けの機能を提供してくれるツールという見方ができます。
こちらが本題で、DuckDBをPostgreSQLの内部に組み込んでしまうという試みです。

DuckDBを組み込むことで達成したいことは、大きく以下の3つといえます。

  • コンピューティングの最適化: DuckDBはOLAP向けに最適化されているため、複雑なSQLの実行時における性能向上を目的としてDuckDBのクエリエンジンを採用したいです。
  • ストレージの最適化: DuckDBは巨大データの分析に適した列指向[1]のDBであり、またParquet[2]やIceberg[3]といったDWH向けのフォーマットに対応していることから、データ配置の最適化を目的としてDuckDBのストレージエンジンを採用したいです。
  • 既存機能との統合: DBの全機能をDuckDBに依存するのではなく、既存のPostgreSQL機能と組み合わせて使うことでOLAP, OLTP性能の両立を目指したいです。

今のところこの3点を完全に実現している拡張は自分の観測範囲にはありませんが、そこに近い位置にありつつ継続的に開発が続けられている有力な拡張はいくつかあります。

2025年12月現在で、頻繁に更新があり、かつ個人的に注目しておきたいと感じる拡張は pg_duckdb, pg_mooncake, pg_lake の3つです。次項からそれぞれについて詳しく紹介します。

pg_duckdb

pg_duckdb はDuckDB公式が開発している拡張機能です。この拡張は主にDuckDBのクエリエンジンでPostgreSQLのテーブルをスキャンする機能と、DuckDBのサポートする外部データをスキャンする機能を提供しています。
また、DuckDBをPostgreSQLに組み込むコアの実装を提供するという役割も担っており、他に開発されている拡張群もpg_duckdbをベースにしているものが多いです[4]

使用例

Docker Hubのimageを使ってサーバーを起動します。

docker run -itd --name pgduckdb -e POSTGRES_PASSWORD=postgres pgduckdb/pgduckdb:17-v1.0.0

サーバーで以下のコマンドを実行し、簡単なテストテーブルを作成します。

drop table if exists test;
create table test as select generate_series(1, 1000) as id, gen_random_uuid()::text as val;

クエリエンジンを切り替えるため、 force_extension パラメータを有効にします。

set duckdb.force_execution = true;

これでクエリエンジンがduckdbになりました。実行計画を見てみましょう。

postgres=# explain analyze select * from test where val = val;
                                              QUERY PLAN
------------------------------------------------------------------------------------------------------
 Custom Scan (DuckDBScan)  (cost=0.00..0.00 rows=0 width=0) (actual time=0.001..0.002 rows=0 loops=1)
   DuckDB Execution Plan:

 ┌─────────────────────────────────────┐
 │┌───────────────────────────────────┐│
 ││    Query Profiling Information    ││
 │└───────────────────────────────────┘│
 └─────────────────────────────────────┘
 EXPLAIN ANALYZE  SELECT id, val FROM pgduckdb.public.test WHERE (val = val)
 ┌────────────────────────────────────────────────┐
 │┌──────────────────────────────────────────────┐│
 ││              Total Time: 0.0099s             ││
 │└──────────────────────────────────────────────┘│
 └────────────────────────────────────────────────┘
 ┌───────────────────────────┐
 │           QUERY           │
 └─────────────┬─────────────┘
 ┌─────────────┴─────────────┐
 │      EXPLAIN_ANALYZE      │
 │    ────────────────────   │
 │           0 Rows          │
 │          (0.00s)          │
 └─────────────┬─────────────┘
 ┌─────────────┴─────────────┐
 │         PROJECTION        │
 │    ────────────────────   │
 │             id            │
 │            val            │
 │                           │
 │         1000 Rows         │
 │          (0.00s)          │
 └─────────────┬─────────────┘
 ┌─────────────┴─────────────┐
 │         TABLE_SCAN        │
 │    ────────────────────   │
 │        Table: test        │
 │                           │
 │        Projections:       │
 │            val            │
 │             id            │
 │                           │
 │    Filters: (val = val)   │
 │                           │
 │         1000 Rows         │
 │          (0.02s)          │
 └───────────────────────────┘


 Planning Time: 0.550 ms
 Execution Time: 0.528 ms
(50 rows)

見慣れたPostgreSQLの実行計画とはかなり違うフォーマットで表示されていますが、これはDuckDBの実行計画です。Custom Scan内でクエリ全体をDuckDBのエンジンで実行して返却する、という仕組みになっています。

DuckDBのクエリエンジンを使う際の問題点について

クエリの全体をDuckDBが実行する都合上、 DuckDBで扱える機能しか使えない という問題があります。DuckDBのクエリエンジンで実行できないクエリを実行した場合、PostgreSQLのクエリエンジンにfallbackされます。

この挙動を確認するには、独自の関数を作って実行するのが簡単です。試しにtextを引数にそのtextをそのまま返すという簡単な関数を用意します。

create or replace function sonomama(str text) returns text as $$ select str; $$ language sql strict immutable;

この関数をクエリ内で実行すると、以下のようにWarningが表示され、通常のPostgreSQLのスキャンが走ります。

postgres=# explain analyze select * from test where val = sonomama(val);
WARNING:  (PGDuckDB/CreatePlan) Prepared query returned an error: 'Catalog Error: Scalar Function with name sonomama does not exist!
Did you mean "json"?

LINE 1: SELECT id, val FROM pgduckdb.public.test WHERE (val = sonomama(val))
                                                              ^
                                              QUERY PLAN
------------------------------------------------------------------------------------------------------
 Seq Scan on test  (cost=0.00..26.00 rows=1000 width=41) (actual time=0.011..0.125 rows=1000 loops=1)
   Filter: (val IS NOT NULL)
 Planning Time: 1.354 ms
 Execution Time: 0.180 ms
(4 rows)

実用性を考えるとこれはかなり痛い問題のように感じます。というのも、これは独自関数や独自型を提供するような3rd Party拡張のほとんどと共存できないことを意味するからです。
PostgreSQLの多彩な拡張にDuckDBの高いOLAP性能を乗算する、というような使い方は現状難しいようです。

クエリエンジンによるパフォーマンス向上について

pg_duckdbのblogによると、クエリエンジンの変更により1500倍以上も高速化するとされています。
この数字だけ見るととても魅力的に映りますが、当然これはエンジンの変更によってあらゆるクエリが1500倍になることを意味するわけではありません。
この例で提示されているのはTPC-DSというベンチマークの1クエリ[5]ですが、TPCベンチマークはあくまで全く同じデータ、同じSQLに対するクエリ性能の評価を目的とするものです。実務においては等価なクエリに書き換えればPostgreSQLでもほぼ同じ速度を出すことができます。
「同じクエリでもより効率良い実行計画を選択できる」という点でDuckDBのクエリエンジンが優秀であることは疑いようがありませんが、何でもかんでも速くなる銀の弾丸でないことには注意が必要です。

pg_mooncake

pg_duckdbで扱えるデータストアはPostgreSQLのヒープか外部データであり、PostgreSQL内部に列指向ストレージを直接保持するわけではありません。
これに対して、PostgreSQLに新しいIceberg形式の列指向アクセスメソッドを追加し、DuckDBのクエリエンジンを用いてIcebergテーブルをスキャンする機能を提供する拡張もいくつかあります。その一つが pg_mooncake です。

使用例

Docker Hubのimageを使ってサーバーを起動します。

docker run -itd --name pgmooncake -e POSTGRES_PASSWORD=postgres mooncakelabs/pg_mooncake:17-v0.2-preview

サーバーで以下のコマンドを実行し、簡単なテストテーブルを作成します。

create extension pg_mooncake;
drop table if exists test;
create table test as select generate_series(1, 1000) as id, gen_random_uuid()::text as val;

pg_mooncakeでは、既存の行指向テーブルを複製して列指向テーブルを作る、という形でテーブルを作成します。
mooncake.create_table 関数によって既存テーブルを複製します。
以下のように親テーブルが pg_publication_tables に追加されることから、複製に論理レプリケーションの仕組みを応用していることが分かります。

postgres=# call mooncake.create_table('test_iceberg', 'test');
CALL
postgres=# select * from pg_publication_tables;
   pubname    | schemaname | tablename | attnames | rowfilter
--------------+------------+-----------+----------+-----------
 moonlink_pub | public     | test      | {id,val} |
(1 row)

postgres=# select count(*) from test_iceberg ;
 count
-------
  1000
(1 row)

test テーブルの操作に追従して、複製した test_iceberg テーブルの中身も変わります。

postgres=# insert into test values (1001, 'hoge');
INSERT 0 1

postgres=# select * from test_iceberg where val = 'hoge';
  id  | val
------+------
 1001 | hoge
(1 row)

postgres=# update test set val = 'fuga' where val = 'hoge';
UPDATE 1
postgres=# select * from test_iceberg where val = 'fuga';
  id  | val
------+------
 1001 | fuga
(1 row)

postgres=# delete from test where val = 'fuga';
DELETE 1
postgres=# select count(*) from test_iceberg;
 count
-------
  1000
(1 row)

ちなみに複製したテーブルは親テーブルを削除しても残ります。ただしIcebergテーブルへの直接更新はサポートされていないため、参照専用のテーブルになります(直接更新しようとするとエラーになる)。

postgres=# drop table test;
DROP TABLE
postgres=# select count(*) from test_iceberg;
 count
-------
  1000
(1 row)

postgres=# insert into test_iceberg values (1001, 'hoge');
ERROR:  (PGDuckDB/CreatePlan) Prepared query returned an error: 'Not implemented Error: PlanInsert not implemented

クエリエンジンの扱いについて

pg_mooncakeではIcebergテーブルのスキャンが含まれるクエリにおいて、クエリ全体をDuckDBのクエリエンジンで実行します。そのため、pg_duckdbと同様にDuckDBで扱えない機能との併用ができません。

-- DuckDBが扱えるクエリ
postgres=# explain analyze select * from test_iceberg where val = val;
                                              QUERY PLAN
------------------------------------------------------------------------------------------------------
 Custom Scan (DuckDBScan)  (cost=0.00..0.00 rows=0 width=0) (actual time=0.001..0.002 rows=0 loops=1)
   DuckDB Execution Plan:

 ┌─────────────────────────────────────┐
 │┌───────────────────────────────────┐│
 ││    Query Profiling Information    ││
 │└───────────────────────────────────┘│
 └─────────────────────────────────────┘
 EXPLAIN ANALYZE  SELECT id, val FROM mooncake.public.test_iceberg WHERE (val = val)
 ┌────────────────────────────────────────────────┐
 │┌──────────────────────────────────────────────┐│
 ││              Total Time: 0.0041s             ││
 │└──────────────────────────────────────────────┘│
 └────────────────────────────────────────────────┘
 ┌───────────────────────────┐
 │           QUERY           │
 └─────────────┬─────────────┘
 ┌─────────────┴─────────────┐
 │      EXPLAIN_ANALYZE      │
 │    ────────────────────   │
 │           0 Rows          │
 │          (0.00s)          │
 └─────────────┬─────────────┘
 ┌─────────────┴─────────────┐
 │         PROJECTION        │
 │    ────────────────────   │
 │             id            │
 │            val            │
 │                           │
 │         1000 Rows         │
 │          (0.00s)          │
 └─────────────┬─────────────┘
 ┌─────────────┴─────────────┐
 │         TABLE_SCAN        │
 │    ────────────────────   │
 │    Table: test_iceberg    │
 │                           │
 │        Projections:       │
 │            val            │
 │             id            │
 │                           │
 │    Filters: (val = val)   │
 │    Total Files Read: 3    │
 │                           │
 │         1000 Rows         │
 │          (0.01s)          │
 └───────────────────────────┘


 Planning Time: 0.733 ms
 Execution Time: 0.469 ms
(51 rows)

現状ではIcebergテーブルのスキャンが完全にDuckDB依存のため、クエリ自体がエラーになってしまいます。

-- DuckDBが扱えないクエリ
postgres=# create or replace function sonomama(str text) returns text as $$ select str; $$ language sql strict immutable;
CREATE FUNCTION
postgres=# explain analyze select * from test_iceberg where val = sonomama(val);
ERROR:  (PGDuckDB/CreatePlan) Prepared query returned an error: 'Catalog Error: Scalar Function with name sonomama does not exist!
Did you mean "json"?

LINE 1: ... id, val FROM mooncake.public.test_iceberg WHERE (val = sonomama(val))
                                                                   ^

この問題自体は認識されていて、将来のバージョンで改善する予定はあるようですがなかなか実現は難しそうな印象を受けました。
https://github.com/Mooncake-Labs/pg_mooncake/discussions/91

pg_lake

pg_lake はSnowflakeというDWHの開発元が最近公開した拡張で、pg_mooncakeと同様にPostgreSQL上でIcebergテーブルを扱えるようになります。
PostgreSQLにIcebergアクセスメソッドを提供する点、DuckDBのクエリエンジンを利用する点など、pg_lakeの立ち位置はpg_mooncakeに似ています。一方でそれを実現するためのアーキテクチャは大きく異なります。

pg_lakeの一番の特徴として、DuckDBによるクエリエンジンが完全に別プロセスとして分離されている点が挙げられます。
https://github.com/Snowflake-Labs/pg_lake?tab=readme-ov-file#architecture
アーキテクチャ図を見ると、IcebergテーブルはPostgreSQL本体に格納しつつ、クエリはPostgreSQL本体とは別のpgduck_serverというプロセスによって実行する、という仕組みのようです。

使用例

公式のimageがまだ提供されていないため、レポジトリのdocker配下のガイド に従って環境を構築します[6]。  
docker-compose.ymlを見るとアーキテクチャ図の通り、本体のコンテナとは別に pgduck-server というコンテナが必要であることが分かります。

pg_lake-postgresコンテナ上で以下のように iceberg というテーブルアクセスメソッドを指定してテーブルを作成します。
テーブルに対して直接CRUDすることも可能です。

postgres=# create table test_iceberg using iceberg as select generate_series(1, 1000) as id, gen_random_uuid()::text as val;
CREATE TABLE AS

postgres=# insert into test_iceberg values (1001, 'hoge');
INSERT 0 1
postgres=# select * from test_iceberg where val = 'hoge';
  id  | val  
------+------
 1001 | hoge
(1 row)

postgres=# update test_iceberg set val = 'fuga' where val = 'hoge';
UPDATE 1
postgres=# select * from test_iceberg where val = 'fuga';
  id  | val  
------+------
 1001 | fuga
(1 row)

postgres=# delete from test_iceberg where val = 'fuga';
DELETE 1
postgres=# select count(*) from test_iceberg ;
 count 
-------
  1000
(1 row)

クエリエンジンの扱いについて

これまでの2拡張と同様に、カスタム関数を用いて関数を作成し、Icebergテーブルに対して使用してみます。

postgres=# create or replace function sonomama(str text) returns text as $$ select str; $$ language sql strict immutable;
CREATE FUNCTION
postgres=# explain analyze select * from test_iceberg where val = sonomama(val);
                                                      QUERY PLAN                                                       
-----------------------------------------------------------------------------------------------------------------------
 Foreign Scan on test_iceberg  (cost=100.00..120.00 rows=1000 width=0) (actual time=2.094..2.395 rows=1000.00 loops=1)
   Engine: DuckDB
   ->  READ_PARQUET 
         Filters: (val IS NOT NULL)
         Function: READ_PARQUET
         Estimated Cardinality: 200
   Buffers: shared hit=5
 Planning Time: 0.126 ms
 Execution Time: 3.461 ms
(9 rows)

なんとクエリが成功してしまいました。実行計画を見る限りsql関数が解析され、DuckDBのクエリエンジン内で実行できる形に置き換わっているように見えます。

もう少し追試をしてみましょう。DuckDBで扱えない型の例として、range型を含めたIcebergテーブルの作成はできるでしょうか。

postgres=# create table range_test using iceberg as select generate_series(1,1000) as id, int4range(1, 100) as val;
CREATE TABLE AS

問題なく作成することができました。ではこのテーブルに対して絞り込みをかけてみます。

postgres=# explain analyze select * from range_test where id < 100 and val && int4range(10, 20);
                                                   QUERY PLAN                                                    
-----------------------------------------------------------------------------------------------------------------
 Foreign Scan on range_test  (cost=100.00..122.50 rows=10 width=0) (actual time=2.715..2.787 rows=99.00 loops=1)
   Filter: (val && '[10,20)'::int4range)
   Engine: DuckDB
   ->  READ_PARQUET 
         Filters: id<100
         Function: READ_PARQUET
         Estimated Cardinality: 200
   Buffers: shared hit=5
 Planning Time: 0.147 ms
 Execution Time: 4.234 ms
(10 rows)

このように問題なくスキャンが成功しました。驚くべきことに、idに対しての絞り込みはDuckDB側で、valに対しての絞り込みはPostgreSQL側で実行できていることが分かります。これはまさにpg_mooncakeが目指している挙動であり、既存のPostgreSQL機能との統合という観点でも決め手になり得る特長だと感じました。
一方でクエリ全体のうちDuckDBのクエリエンジンを使うのはあくまでForeign Scan内であり、クエリの全体にわたって高度に最適化されたDuckDBのクエリエンジンを使う、というような用途には適さないようです。

まとめ

自分が観測する範囲で、PostgreSQL × DuckDB連携のうち注目すべきと感じた拡張をいくつか紹介しました。
紹介したいずれの拡張に関してもParquetやIcebergの活用、クエリエンジンの代替などOLAP向けの機能をPostgreSQLに持ち込めるという点で非常に有用な拡張だと感じています。とはいえ全く異なるDBを掛け合わせるという試みにかなりの難しさがあるのも事実のようで、絶対的な正解はまだ模索中ということも感じました。
ただ冒頭で述べた通り非常に速いスピードで進歩し続ける領域でもあり、我々ユーザーが両者のシナジーを十分に生かせる未来はそう遠くないのではないかと思います。今回は各拡張の基本的な紹介にとどまってしまいましたが、パフォーマンス検証なども含めて引き続きチェックを続けていきたいです。

この記事を書いた人

吉田 侑弥

意味のあるブログを書くという点で最近はもっぱらAIとの戦いとなっており、毎度かなり苦戦を強いられています。
今回はpg_duckdbについてChatGPTに尋ねたところduckdbのPostgreSQL連携について回答してきたので、安心して記事を書くことができました。

脚注
  1. データ上で同じ列をまとめて配置するフォーマットです。特定の列に対する計算時にデータ取得効率が良い、列単位で高効率な圧縮ができるなどのメリットがある一方で、更新が苦手というデメリットがあります。 ↩︎

  2. データを格納するためのストレージフォーマットです。列指向かつ高圧縮で、大規模なデータの分析に有用です。 ↩︎

  3. ファイル群を1つのテーブルとして扱うためのテーブルフォーマットです。大規模なデータを扱うことを念頭に設計されています。 ↩︎

  4. 逆にpg_duckdbと同じ機能を提供するような拡張は統合が進んでいくことが予想されます。実際にpg_analyticsのような有力だったがアーカイブされた拡張もあります。 ↩︎

  5. 特にPostgreSQLが苦手とされているクエリでの比較であり、実際には全シナリオにおいて同様に劣っているわけでもないです。 ↩︎

  6. 筆者の環境ではdocker buildに失敗してしまったため、Docker Hubで公開されている非公式のimageを使用しました。PostgreSQLのバージョンは18、pg_lakeのバージョンはv1.0.0です。 ↩︎

FORCIA Tech Blog

Discussion