PythonでDuckDBを使う—CSV/ParquetをSQLのまま集計する

PythonでDuckDBを使う—CSV/ParquetをSQLのまま集計する | mohablog

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に展開します。今回の集計で要るのは categoryamount の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 DataFramePolarsのパイプラインに繋ぐ
.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と往復できる
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次