DuckDBでSQLクエリを叩くときにCSV形式よりもApache Parquet形式のほうが早い理由

DuckDBでSQLクエリを叩くときにCSV形式よりもApache Parquet形式のほうが早い理由

先に理由を書きますが、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 BYOVER 句を使う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)

Plain text
"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形式で

Plain text
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
)
Plain text
┌─────────────────────────────────────┐
│┌───────────────────────────────────┐│
││    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形式で

Plain text
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()
Plain text
┌─────────────────────────────────────┐
│┌───────────────────────────────────┐│
││    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に書き出すときに簡単に指定できるので積極的に有効にしたらいいですね。

Apache Parquetの特徴の画面

https://parquet.apache.org/docs/file-format

[3] https://parquet.apache.org/docs/file-format/pageindex/

DuckDBでMetadataを読み取れます[4]。今回の記事のためだけではなく実際のシステムや運用にParquet形式を導入するなら適切に利用されているか確認するためにも定期的に確認するのが良いと思います。

[4] https://duckdb.org/docs/stable/data/parquet/metadata

Plain text
┌───────┬────────────┬────────────────────────┬────────────────────────┐
│  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形式よりも高速です。今後そういった話もしていきたいなと思っています。

次も思ったことを素直に書いていきます。