目次
先に理由を書きますが、ParquetのRow Group単位で並列処理できるしフルスキャンを避けれるからです。
システムでも分析タスクでもSQLクエリを叩く(問い合わせ)したらなる早でレスポンス欲しいですよね。この記事で検証を進めながらなる早でレスポンスもらえる方法と仕組みをシェアします。
DuckDBの特徴
例えばCSVやJSONなどのファイルから中身の読み取りが出来るようになってます。2025年4月16日時点で汚れたCSVファイルの読み取り評価[1]ではトップ成績です。Amazon S3やGoogle Cloud Storageなどに展開されているファイルも読み込みにいけます。2025年9月16日に1.4.0がリリースされましたがSortが再設計されて ORDER BY や OVER 句を使うWindow関数の性能向上[2]が期待できます。手元の端末で以前諦めてたならもう一度トライしてみてはどうでしょうか。
[1] https://github.com/HPI-Information-Systems/Pollock
[2] https://duckdb.org/2025/09/24/sorting-again
用途などは弊社のブログでも何度か触れており、他所でもたくさん事例が書かれているので今回は割愛します。弊社のDuckDBに関する記事は下記リンクから見れます。
https://blog.grasys.io/tags/duckdb
データのサンプル
1年間複数の台数から取得したCPU使用率です。
sample.csv (4.1G)
"time","hostname","user","system"
"2024-10-31 15:00:00.000000Z","e21202a5","3.1650085","1.0326155"
"2024-10-31 15:00:00.000000Z","7cc618e8","0.3713847","0.25934687"
"2024-10-31 15:00:01.000000Z","f8e1706d","1.8588532","0.6385213"
"2024-10-31 15:00:02.000000Z","717b9728","28.227425","4.431438"
"2024-10-31 15:00:02.000000Z","c2bb45ce","28.17161","4.7762694"
...
"2025-10-31 14:59:57.000000Z","a4bb5182","4.508951","1.5810608"
"2025-10-31 14:59:57.000000Z","50d9c03f","19.110445","3.3137712"
"2025-10-31 14:59:59.000000Z","f3ad8578","0.8832517","0.48557913"
"2025-10-31 15:00:00.000000Z","270cb309","3.9902759","0.7167407"
"2025-10-31 15:00:00.000000Z","4e53b793","9.172119","0.73393726"
検証環境
- Machine Type: n1-standard-4 (4 vCPU / 15 GB RAM)
- CPU Platform: Intel Skylake (x86_64)
- OS: Ubuntu 24.04.3 LTS
- Kernel: Linux 6.14.0-1020-gcp
- Storage: pd-standard (80GB)
- Python: 3.14
- DuckDB: 1.4.2
- Apache Arrow: 22.0.0
SQLクエリを叩く
結果を見てみましょう。それぞれPythonから呼び出します。
CSV形式で
WITH t AS (
SELECT
"time",
hostname,
"user",
system
FROM READ_CSV(
'sample.csv',
auto_detect = FALSE,
header = TRUE,
columns = {
'time': 'TIMESTAMPTZ',
'hostname': 'VARCHAR',
'system': 'FLOAT',
'user': 'FLOAT'
}
)
WHERE
"time" >= CAST('2025-04-01 00:00:00+09' AS TIMESTAMPTZ)
AND "time" < CAST('2025-05-01 00:00:00+09' AS TIMESTAMPTZ)
)
SELECT
"time",
hostname,
field_key,
field_value
FROM (
UNPIVOT t
ON "system", "user"
INTO NAME field_key VALUE field_value
)
┌─────────────────────────────────────┐
│┌───────────────────────────────────┐│
││ Query Profiling Information ││
│└───────────────────────────────────┘│
└─────────────────────────────────────┘
EXPLAIN ANALYZE WITH t AS ( SELECT "time", hostname, "user", system FROM READ_CSV( 'sample.csv', auto_detect = FALSE, header = TRUE, columns = { 'time': 'TIMESTAMP', 'hostname': 'VARCHAR', 'system': 'FLOAT', 'user': 'FLOAT' } ) WHERE "time" >= CAST('2025-04-01 00:00:00+09' AS TIMESTAMPTZ) AND "time" < CAST('2025-05-01 00:00:00+09' AS TIMESTAMPTZ) ) SELECT "time", hostname, field_key, field_value FROM ( UNPIVOT t ON "system", "user" INTO NAME field_key VALUE field_value )
┌────────────────────────────────────────────────┐
│┌──────────────────────────────────────────────┐│
││ Total Time: 30.55s ││
│└──────────────────────────────────────────────┘│
└────────────────────────────────────────────────┘
┌───────────────────────────┐
│ QUERY │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ EXPLAIN_ANALYZE │
│ ──────────────────── │
│ 0 rows │
│ (0.00s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│ #0 │
│ #1 │
│ #4 │
│ #5 │
│ │
│ 11,032,214 rows │
│ (0.00s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ FILTER │
│ ──────────────────── │
│ (field_value IS NOT NULL) │
│ │
│ 11,032,214 rows │
│ (0.03s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ UNNEST │
│ ──────────────────── │
│ 11,032,214 rows │
│ (0.26s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│ time │
│ hostname │
│ unpivot_names │
│ unpivot_list │
│ │
│ 5,516,107 rows │
│ (0.12s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ FILTER │
│ ──────────────────── │
│ (CAST(time AS TIMESTAMP │
│ WITH TIME ZONE) BETWEEN │
│ '2025-03-31 15:00:00+00': │
│ :TIMESTAMP WITH TIME ZONE │
│ AND '2025-04-30 15:00:00 │
│ +00'::TIMESTAMP WITH TIME │
│ ZONE) │
│ │
│ 5,516,107 rows │
│ (32.09s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ TABLE_SCAN │
│ ──────────────────── │
│ Function: READ_CSV │
│ │
│ Projections: │
│ time │
│ hostname │
│ user │
│ system │
│ │
│ Total Files Read: 1 │
│ │
│ 67,513,342 rows │
│ (88.88s) │
└───────────────────────────┘
Parquet形式で
import argparse
from textwrap import dedent
from typing import TYPE_CHECKING
import duckdb
from structlog import get_logger
from internal import (
ExitCode,
main as _main,
)
if TYPE_CHECKING:
from structlog.stdlib import BoundLogger
exit_code = ExitCode()
logger: BoundLogger = get_logger()
def parse_args():
parser = argparse.ArgumentParser(prog="query_pq_202504_range_native_with_explain.py")
args = parser.parse_args()
return args
@_main(exit_code)
def main():
t = duckdb.sql(
dedent("""
SET TimeZone = 'UTC';
EXPLAIN ANALYZE
WITH t AS (
SELECT
"time",
hostname,
"user",
system
FROM READ_PARQUET('sample.parquet')
WHERE
"time" >= CAST('2025-04-01 00:00:00+09' AS TIMESTAMPTZ)
AND "time" < CAST('2025-05-01 00:00:00+09' AS TIMESTAMPTZ)
)
SELECT
"time",
hostname,
field_key,
field_value
FROM (
UNPIVOT t
ON "system", "user"
INTO NAME field_key VALUE field_value
)
""")[1:-1]
)
print(t.fetchone()[1])
if __name__ == "__main__":
main()
┌─────────────────────────────────────┐
│┌───────────────────────────────────┐│
││ Query Profiling Information ││
│└───────────────────────────────────┘│
└─────────────────────────────────────┘
EXPLAIN ANALYZE WITH t AS ( SELECT "time", hostname, "user", system FROM READ_PARQUET('sample.parquet') WHERE "time" >= CAST('2025-04-01 00:00:00+09' AS TIMESTAMPTZ) AND "time" < CAST('2025-05-01 00:00:00+09' AS TIMESTAMPTZ) ) SELECT "time", hostname, field_key, field_value FROM ( UNPIVOT t ON "system", "user" INTO NAME field_key VALUE field_value )
┌────────────────────────────────────────────────┐
│┌──────────────────────────────────────────────┐│
││ Total Time: 0.253s ││
│└──────────────────────────────────────────────┘│
└────────────────────────────────────────────────┘
┌───────────────────────────┐
│ QUERY │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ EXPLAIN_ANALYZE │
│ ──────────────────── │
│ 0 rows │
│ (0.00s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│ #0 │
│ #1 │
│ #4 │
│ #5 │
│ │
│ 11,032,214 rows │
│ (0.00s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ FILTER │
│ ──────────────────── │
│ (field_value IS NOT NULL) │
│ │
│ 11,032,214 rows │
│ (0.02s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ UNNEST │
│ ──────────────────── │
│ 11,032,214 rows │
│ (0.26s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│ time │
│ hostname │
│ unpivot_names │
│ unpivot_list │
│ │
│ 5,516,107 rows │
│ (0.17s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ TABLE_SCAN │
│ ──────────────────── │
│ Function: │
│ READ_PARQUET │
│ │
│ Projections: │
│ time │
│ hostname │
│ user │
│ system │
│ │
│ Filters: │
│ time>='2025-03-31 15:00:00│
│ +00'::TIMESTAMP WITH TIME │
│ ZONE AND time<'2025-04-30│
│ 15:00:00+00'::TIMESTAMP │
│ WITH TIME ZONE │
│ │
│ Total Files Read: 1 │
│ │
│ 5,516,107 rows │
│ (0.30s) │
└───────────────────────────┘
他の書き方ではどうなるのか
CSVとParquet形式で圧倒的な応答の差がありましたが、後述のParquet形式の特徴と合わせて読み込みが早くなった理由を説明します。
Parquetファイル書き出し時には統計情報やPage Indexを書き出すことで最適化の余地があります。ただ、本記事ではまず「CSV形式の場合」と「Parquet形式の場合」の違いに焦点を当て、フォーマットを変えるだけでも効果的という話に留めます。
そのため元データがソート済みだったためか、ソートしてからParquetに書き出しすることが読み取り速度に寄与したのかは未調査です。
こちらの表は何をしたら遅くなったりするのか試行錯誤した履歴です。前述のSQLを少し書き換えながら20回近く試したものです。
| No. | ファイル形式/設定 | クエリの内容 | 総実行時間 (Total Time) | TABLE_SCAN 実行時間 | TABLE_SCAN 処理行数 |
|---|---|---|---|---|---|
| 1 | CSV (TIMESTAMPTZ) | 全件読み込み | 18.28s | 66.93s | 67,513,342行 |
| 2 | 未ソート Parquet (Stats Enabled, Page Index Enabled) | 全件読み込み | 2.37s | 2.49s | 67,513,342行 |
| 3 | 未ソート Parquet (Stats Enabled, Page Index Disabled) | 全件読み込み | 2.35s | 2.45s | 67,513,342行 |
| 4 | 未ソート Parquet (Stats Disabled, Page Index Disabled) | 全件読み込み | 2.32s | 2.43s | 67,513,342行 |
| 5 | CSV (TIMESTAMPTZ) | 2025年4月抽出 (DATE_TRUNC, TZ) | 29.72s | 65.73s | 67,513,342行 |
| 6 | CSV (TIMESTAMP) | 2025年4月抽出 (DATE_TRUNC, +INTERVAL) | 19.46s | 65.14s | 67,513,342行 |
| 7 | CSV (TIMESTAMPTZ) | 2025年4月期間抽出 (BETWEEN, TZ) | 47.85s | 113.15s | 67,513,342行 |
| 8 | 未ソート Parquet (Stats Enabled, Page Index Enabled) | 2025年4月抽出 (DATE_TRUNC, TZ) | 13.70s | 2.65s | 67,513,342行 |
| 9 | 未ソート Parquet (Stats Enabled, Page Index Enabled) | 2025年4月抽出 (DATE_TRUNC, +INTERVAL) | 14.46s | 56.79s | 5,516,107行 |
| 10 | 未ソート Parquet (Stats Enabled, Page Index Enabled) | 2025年4月期間抽出 (BETWEEN, TZ) | 20.19s | 2.81s | 67,513,342行 |
| 11 | 未ソート Parquet (Stats Enabled, Page Index Enabled) | 2025年4月期間抽出 (BETWEEN, +INTERVAL) | 3.70s | 13.90s | 5,516,107行 |
| 12 | ソート済み Parquet (Stats Enabled, Page Index Enabled) | 2025年4月期間抽出 (BETWEEN, +INTERVAL) | 3.66s | 13.78s | 5,516,107行 |
| 13 | ソート済み Parquet (Stats Enabled, Page Index Enabled) | 2025年4月期間抽出 (カラム加工無し) | 0.296s | 0.35s | 5,516,111行 |
| 14 | 未ソート Parquet (Stats Enabled, Page Index Enabled) | 2025年4月期間抽出 (カラム加工無し) | 0.268s | 0.31s | 5,516,111行 |
| 15 | 未ソート Parquet (Stats Enabled, Page Index Disabled) | 2025年4月期間抽出 (BETWEEN, +INTERVAL) | 3.63s | 13.73s | 5,516,107行 |
| 16 | 未ソート Parquet (Stats Disabled, Page Index Disabled) | 2025年4月期間抽出 (BETWEEN, +INTERVAL) | 3.65s | 13.76s | 5,516,107行 |
| 17 | 未ソート Parquet (Stats Enabled, Page Index Disabled) | 2025年4月抽出 (DATE_TRUNC, TZ) | 13.66s | 2.70s | 67,513,342行 |
| 18 | 未ソート Parquet (Stats Disabled, Page Index Disabled) | 2025年4月抽出 (DATE_TRUNC, TZ) | 13.60s | 2.66s | 67,513,342行 |
| 19 | ソート済み Parquet (Stats Disabled, Page Index Disabled) | 2025年4月期間抽出 (カラム加工無し) | 0.582s | 1.65s | 5,516,111行 |
Apache Parquetの特徴
こちらの記事で伝えたい内容に限定してお話すると書き込まれたFileMetaDataを読み取って必要になる “Row group” まで読み飛ばすことが出来ます。”Row group” の中には “Page” がありますがこの中でも読み飛ばすことができますがPage Index[3]が必要になります。
今回は全然カラム数ありませんしテキストもありませんが、大量のカラム数と文字列があるときはPage Index欲しいですね。Parquetに書き出すときに簡単に指定できるので積極的に有効にしたらいいですね。

[3] https://parquet.apache.org/docs/file-format/pageindex/
DuckDBでMetadataを読み取れます[4]。今回の記事のためだけではなく実際のシステムや運用にParquet形式を導入するなら適切に利用されているか確認するためにも定期的に確認するのが良いと思います。
[4] https://duckdb.org/docs/stable/data/parquet/metadata
┌───────┬────────────┬────────────────────────┬────────────────────────┐
│ id │ num_values │ stats_min │ stats_max │
│ int64 │ int64 │ varchar │ varchar │
├───────┼────────────┼────────────────────────┼────────────────────────┤
│ 0 │ 1048576 │ 2024-10-31 15:00:00+00 │ 2024-11-06 12:16:03+00 │
│ 1 │ 1048576 │ 2024-11-06 12:16:04+00 │ 2024-11-12 05:42:02+00 │
│ 2 │ 1048576 │ 2024-11-12 05:42:02+00 │ 2024-11-17 22:33:37+00 │
│ 3 │ 1048576 │ 2024-11-17 22:33:39+00 │ 2024-11-23 15:25:22+00 │
│ 4 │ 1048576 │ 2024-11-23 15:25:23+00 │ 2024-11-29 08:17:25+00 │
│ 5 │ 1048576 │ 2024-11-29 08:17:25+00 │ 2024-12-05 01:09:20+00 │
│ 6 │ 1048576 │ 2024-12-05 01:09:21+00 │ 2024-12-10 15:32:41+00 │
│ 7 │ 1048576 │ 2024-12-10 15:32:41+00 │ 2024-12-15 22:31:50+00 │
│ 8 │ 1048576 │ 2024-12-15 22:31:51+00 │ 2024-12-21 05:30:53+00 │
│ 9 │ 1048576 │ 2024-12-21 05:30:54+00 │ 2024-12-26 12:29:46+00 │
│ 10 │ 1048576 │ 2024-12-26 12:29:46+00 │ 2024-12-31 19:28:39+00 │
│ 11 │ 1048576 │ 2024-12-31 19:28:39+00 │ 2025-01-06 02:28:53+00 │
│ 12 │ 1048576 │ 2025-01-06 02:28:53+00 │ 2025-01-11 13:20:17+00 │
│ 13 │ 1048576 │ 2025-01-11 13:20:17+00 │ 2025-01-17 06:16:25+00 │
│ 14 │ 1048576 │ 2025-01-17 06:16:26+00 │ 2025-01-22 23:12:30+00 │
│ 15 │ 1048576 │ 2025-01-22 23:12:31+00 │ 2025-01-28 16:08:40+00 │
│ 16 │ 1048576 │ 2025-01-28 16:08:41+00 │ 2025-02-03 09:04:53+00 │
│ 17 │ 1048576 │ 2025-02-03 09:04:53+00 │ 2025-02-09 02:01:02+00 │
│ 18 │ 1048576 │ 2025-02-09 02:01:02+00 │ 2025-02-14 18:56:55+00 │
│ 19 │ 1048576 │ 2025-02-14 18:56:55+00 │ 2025-02-20 11:53:02+00 │
│ 20 │ 1048576 │ 2025-02-20 11:53:02+00 │ 2025-02-26 04:49:03+00 │
│ 21 │ 1048576 │ 2025-02-26 04:49:04+00 │ 2025-03-03 21:43:07+00 │
│ 22 │ 1048576 │ 2025-03-03 21:43:08+00 │ 2025-03-09 14:34:56+00 │
│ 23 │ 1048576 │ 2025-03-09 14:34:56+00 │ 2025-03-15 07:26:49+00 │
│ 24 │ 1048576 │ 2025-03-15 07:26:50+00 │ 2025-03-21 00:18:54+00 │
│ 25 │ 1048576 │ 2025-03-21 00:18:54+00 │ 2025-03-26 17:11:17+00 │
│ 26 │ 1048576 │ 2025-03-26 17:11:17+00 │ 2025-04-01 10:03:10+00 │
│ 27 │ 1048576 │ 2025-04-01 10:03:12+00 │ 2025-04-07 02:55:07+00 │
│ 28 │ 1048576 │ 2025-04-07 02:55:07+00 │ 2025-04-12 19:47:07+00 │
│ 29 │ 1048576 │ 2025-04-12 19:47:08+00 │ 2025-04-18 12:39:11+00 │
│ 30 │ 1048576 │ 2025-04-18 12:39:11+00 │ 2025-04-24 05:31:12+00 │
│ 31 │ 1048576 │ 2025-04-24 05:31:12+00 │ 2025-04-29 22:23:20+00 │
│ 32 │ 1048576 │ 2025-04-29 22:23:20+00 │ 2025-05-05 15:15:18+00 │
│ 33 │ 1048576 │ 2025-05-05 15:15:18+00 │ 2025-05-11 08:07:58+00 │
│ 34 │ 1048576 │ 2025-05-11 08:07:59+00 │ 2025-05-17 01:00:00+00 │
│ 35 │ 1048576 │ 2025-05-17 01:00:01+00 │ 2025-05-22 17:51:59+00 │
│ 36 │ 1048576 │ 2025-05-22 17:51:59+00 │ 2025-05-28 10:43:51+00 │
│ 37 │ 1048576 │ 2025-05-28 10:43:51+00 │ 2025-06-03 03:35:45+00 │
│ 38 │ 1048576 │ 2025-06-03 03:35:45+00 │ 2025-06-08 20:27:48+00 │
│ 39 │ 1048576 │ 2025-06-08 20:27:48+00 │ 2025-06-14 13:19:58+00 │
│ 40 │ 1048576 │ 2025-06-14 13:19:59+00 │ 2025-06-20 06:11:52+00 │
│ 41 │ 1048576 │ 2025-06-20 06:11:54+00 │ 2025-06-25 23:03:56+00 │
│ 42 │ 1048576 │ 2025-06-25 23:03:56+00 │ 2025-07-01 15:56:15+00 │
│ 43 │ 1048576 │ 2025-07-01 15:56:15+00 │ 2025-07-07 08:48:11+00 │
│ 44 │ 1048576 │ 2025-07-07 08:48:12+00 │ 2025-07-13 01:40:09+00 │
│ 45 │ 1048576 │ 2025-07-13 01:40:09+00 │ 2025-07-18 18:32:04+00 │
│ 46 │ 1048576 │ 2025-07-18 18:32:05+00 │ 2025-07-24 11:23:59+00 │
│ 47 │ 1048576 │ 2025-07-24 11:24:00+00 │ 2025-07-30 04:16:06+00 │
│ 48 │ 1048576 │ 2025-07-30 04:16:07+00 │ 2025-08-04 21:08:12+00 │
│ 49 │ 1048576 │ 2025-08-04 21:08:13+00 │ 2025-08-10 14:00:20+00 │
│ 50 │ 1048576 │ 2025-08-10 14:00:21+00 │ 2025-08-16 06:52:16+00 │
│ 51 │ 1048576 │ 2025-08-16 06:52:17+00 │ 2025-08-21 23:44:22+00 │
│ 52 │ 1048576 │ 2025-08-21 23:44:22+00 │ 2025-08-27 16:36:18+00 │
│ 53 │ 1048576 │ 2025-08-27 16:36:19+00 │ 2025-09-02 09:28:21+00 │
│ 54 │ 1048576 │ 2025-09-02 09:28:22+00 │ 2025-09-08 02:20:21+00 │
│ 55 │ 1048576 │ 2025-09-08 02:20:22+00 │ 2025-09-13 19:12:31+00 │
│ 56 │ 1048576 │ 2025-09-13 19:12:31+00 │ 2025-09-19 12:07:33+00 │
│ 57 │ 1048576 │ 2025-09-19 12:07:33+00 │ 2025-09-25 04:59:33+00 │
│ 58 │ 1048576 │ 2025-09-25 04:59:34+00 │ 2025-09-30 21:51:41+00 │
│ 59 │ 1048576 │ 2025-09-30 21:51:41+00 │ 2025-10-06 14:43:43+00 │
│ 60 │ 1048576 │ 2025-10-06 14:43:44+00 │ 2025-10-12 07:35:47+00 │
│ 61 │ 1048576 │ 2025-10-12 07:35:49+00 │ 2025-10-18 00:28:12+00 │
│ 62 │ 1048576 │ 2025-10-18 00:28:13+00 │ 2025-10-23 17:20:17+00 │
│ 63 │ 1048576 │ 2025-10-23 17:20:17+00 │ 2025-10-29 10:12:16+00 │
│ 64 │ 404478 │ 2025-10-29 10:12:16+00 │ 2025-10-31 15:00:00+00 │
├───────┴────────────┴────────────────────────┴────────────────────────┤
│ 65 rows 4 columns │
└──────────────────────────────────────────────────────────────────────┘
Row group idが26~32の範囲だけを読み取ることを期待したら730万件ぐらいですが、実際のSCANはさらに少なくて550万件ぐらいですよね。これだけ読み飛ばしできることが早い読み込み結果に寄与したことは間違いありません。
最後に思うこと
Parquet形式は見てのとおりmetadataを色々書き込む必要がありますが、streaming用途であれば、Arrow IPC形式というものがあり特にencodeとdecodeがParquet形式よりも高速です。今後そういった話もしていきたいなと思っています。
次も思ったことを素直に書いていきます。




