pd.read_parquet() でファイル全体をメモリに展開してから groupby。集計したいのは1列なのに、毎回これをやっていませんか。DuckDB ならファイルをロードせず、SQLのまま直接集計できます。pandasのDataFrameもそのままテーブルとして読めるので、置き換えではなく併用が現実的な選択肢になります。
pandasのDataFrameにそのままSQLを投げる
DuckDBの入口は duckdb.sql() です。引数のSQL文を実行して結果オブジェクトを返す。ここで効くのが、スコープ内のDataFrameを変数名のままFROM句に書ける挙動です。
import duckdb
import pandas as pd
small = pd.DataFrame({"store_id": [1, 2, 3], "name": ["A", "B", "C"]})
out = duckdb.sql("SELECT name, store_id * 10 AS x FROM small ORDER BY x DESC").df()
print(out.to_string(index=False))
name x
C 30
B 20
A 10
変数 small をテーブル名として参照しています。pandas / Polars / Arrow のオブジェクトを名前で認識する仕組みで、公式ドキュメントの “Directly Accessing DataFrames and Arrow Objects” セクションに説明がある。コピーは挟まりません。
変数名がそのままテーブルになる
明示的な登録は不要です。DuckDBが実行時にPythonのローカル変数・グローバル変数を走査し、該当する名前を仮想テーブルとして解決する。複数のDataFrameをJOINする場合も、両方を変数名で書けば済みます。
結果はまたDataFrameに戻せる
結果オブジェクトに .df() を付けるとpandas DataFrameに戻る。SQLで重い集計だけ済ませて、後段の整形やプロットはpandas側で続ける、という分業が組めます。SQLとDataFrameの往復にロード処理が要らないのがDuckDBの軸足です。
CSVとParquetをロードせずに集計する
数GBのCSVを pd.read_csv() で開いてMemoryErrorで落ちる。集計に要るのは一部の列だけなのに、pandasはまずファイル全体をDataFrameに展開するからです。DuckDBはこの順番を変えます。read_parquet() と read_csv() をFROM句に置くだけで、ファイルをロードせずSQLが走る。
duckdb.sql("""
SELECT category, sum(amount) AS total
FROM read_parquet('sales.parquet')
GROUP BY category
ORDER BY total DESC
""").df()
拡張子が .parquet なら関数を省いて FROM 'sales.parquet' でも通ります。CSVの自動検出も同様で、区切り文字や型は read_csv() が推定する。明示したいときだけ read_csv('x.csv', header=False, sep=',') のように引数を渡します。
ファイルパスを書くだけ
事前のテーブル定義もスキーマ宣言も要りません。Parquetはカラムごとの型情報を持つので、DuckDBはそれを読んでSQLの型に対応づける。手元のCSVをちょっと集計したいだけの場面で、DataFrameを経由する手間が消えます。
複数ファイルもリモートもひとつのクエリで
ワイルドカードとURLが使えます。日付で分割したログを横断するときに効く書き方です。
# フォルダ内の全Parquetを1テーブル扱い
duckdb.sql("SELECT count(*) FROM read_parquet('logs/2026-*.parquet')")
# HTTPS越しのファイルも直接
duckdb.sql("SELECT * FROM read_parquet('https://example.com/data.parquet') LIMIT 5")
ファイルのリストを渡す形 read_parquet(['a.parquet', 'b.parquet']) も同じ結果になります。glob・リモート・リストのどれを使っても、後続のGROUP BYやJOINの書き方は変わりません。
WHEREで絞ればさらに読まない
列だけでなく行も絞れます。WHERE句の条件をDuckDBがファイル走査側に押し込むため(predicate pushdown)、Parquetの統計情報で条件に外れるブロックは読み飛ばす。
duckdb.sql("""
SELECT store_id, sum(amount) AS total
FROM 'sales.parquet'
WHERE category = 'food'
GROUP BY store_id
ORDER BY total DESC
LIMIT 3
""").df()
store_id total
158 104040302.0
131 103309797.0
145 103284177.0
全2000万行のうち food だけを拾って店舗別に合計した結果です。条件で削れる行が多いほど、読み込み量はさらに減ります。pandasなら全行を展開してからのブール索引になる工程が、ここでは走査の手前で消える。
pandasで全部読む場合と何が違うか
同じ集計を二通りで実測しました。pandasは「全部読んでからgroupby」、DuckDBは「ファイルを直接SQL」。データは2000万行・63.7MBのParquet(store_id / category / amount の3列)。マシンはローカルのMacです。
import time, pandas as pd, duckdb
t = time.perf_counter()
pd.read_parquet("sales.parquet").groupby("category")["amount"].sum()
print(f"pandas : {time.perf_counter() - t:.2f}s")
t = time.perf_counter()
duckdb.sql("SELECT category, sum(amount) FROM 'sales.parquet' GROUP BY category").df()
print(f"duckdb : {time.perf_counter() - t:.2f}s")
pandas : 3.56s
duckdb : 0.04s
同じ結果に対して 3.56秒が0.04秒。最初に数字を見たときは桁を間違えたかと思って3回測り直しました。差が極端に見えますが、種を割れば単純な話です。
速い理由は「必要な列だけ読む」
pandasの read_parquet() はファイルの全列をメモリ上のDataFrameに展開します。今回の集計で要るのは category と amount の2列だけ。なのに store_id も含めて2000万行ぶんを読み、Python側のオブジェクトに変換してからgroupbyが走る。
DuckDBはSQLを見て、参照される列だけParquetから取り出します(projection pushdown)。集約もストリーミングで進むため、全行をメモリに並べてからまとめる工程がない。読む量と中間データの両方が減るので、この差が出ます。比較はあくまでgroupby-sumひとつ。pandasが不利なのではなく、列を絞れる問い合わせほどDuckDBの取り分が大きいと読むのが正確です。
メモリより大きいデータを集計する
DuckDBは larger-than-memory(アウト・オブ・コア)処理に対応します。メモリ上限を絞っても、データがそれを超える集計が通る。
con = duckdb.connect()
con.execute("SET memory_limit='200MB'")
con.execute("SET temp_directory='/tmp/duck_spill'")
con.sql("SELECT count(*) AS c, avg(amount) AS a FROM 'sales.parquet'").fetchall()
[(20000000, 5049.85858055)]
上限200MBでも2000万行の集計が返ります。メモリに収まらない分はディスクへ退避する。公式ドキュメントの “Spilling to Disk” セクションが temp_directory の設定先を説明していて、既定では永続モードで データベース名.tmp ディレクトリが作られます。
ただしメモリ外で動かない演算もある
アウト・オブ・コアが効くのは “Blocking Operators” に挙がる GROUP BY / JOIN / ORDER BY / WINDOW の4種です。この外には例外があります。list() や string_agg() のように全要素を保持する集約、PIVOT、ブロッキング演算子が複数重なるクエリは、メモリ外処理の制約が残る。巨大データで全行を1リストに畳むような書き方は、memory_limitを絞った状態だと失敗しうると考えてください。
集計結果をParquetに書き出す
読むだけでなく書き出しもSQLで完結します。COPY ... TO でクエリ結果をそのままファイルに落とせる。中間集計を保存して翌日のクエリで読み直す、という運用が一行で書けます。
con = duckdb.connect()
con.execute("""
COPY (
SELECT category, sum(amount) AS total
FROM 'sales.parquet'
GROUP BY category
) TO 'summary.parquet' (FORMAT parquet)
""")
2000万行を5行に畳んだ summary.parquet は350バイト。元の63.7MBと比べれば、集計後だけ残す効き目がわかります。公式の “Writing Data to Disk” が FORMAT parquet 以外にCSV・JSONも挙げていて、出力先を TO 'out.csv' (FORMAT csv, HEADER) と書けばCSVになる。
結果の取り出し方を使い分ける
結果オブジェクトは用途に応じて変換メソッドを選びます。公式の “Result Conversion” にある対応一覧。
| メソッド | 返る型 | 使う場面 |
|---|---|---|
.fetchall() | Pythonのlist(タプル) | 少数行をそのままループで回す |
.df() | pandas DataFrame | 後段をpandasで整形・可視化する |
.pl() | Polars DataFrame | Polarsのパイプラインに繋ぐ |
.arrow() | Arrow Table | 別システムへゼロコピーで渡す |
.fetchnumpy() | NumPy配列のdict | 数値計算に直接流す |
SQLの結果を .df() でpandasに戻し、そのDataFrameをまた次のクエリのテーブルとして使う。この循環が、replacement scanと変換メソッドの組み合わせで成り立ちます。
永続化とインメモリの使い分け
引数なしの duckdb.connect() はインメモリDB。プロセスが終われば消えます。集計の使い捨てや、ファイルを直接読む用途ならこれで足ります。
ファイル名を渡した duckdb.connect('analytics.duckdb') は永続DB。作ったテーブルが .duckdb ファイルに残り、次回つなぎ直せば読める。”Persistent Storage” の項のとおり、同じファイルを他のDuckDBクライアントからも開けます。中間結果を何度も使い回すなら永続、その場限りならインメモリ。
まとめ
現行は DuckDB v1.5.3(LTSは v1.4系)、Python 3.9以降で pip install duckdb だけで入ります。pandasの代替ではなく、SQLが得意な集計を肩代わりさせる相棒です。
- スコープ内のDataFrameは変数名のままFROM句に書ける(replacement scan、コピーなし)
- CSV・Parquetは
read_csv()/read_parquet()でロードせず直接SQL集計できる - 列を絞れるクエリほど、必要な列だけ読むDuckDBの速度差が大きい(実測で3.56秒→0.04秒)
memory_limitを絞ってもGROUP BYやJOINはディスク退避で通る。ただしlist()やPIVOTには制約が残る- 結果は
.df()/.pl()/.arrow()などで取り出し、pandasやPolarsと往復できる

