grasys blog

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)

"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 処理行数
1CSV (TIMESTAMPTZ)全件読み込み18.28s66.93s67,513,342行
2未ソート Parquet (Stats Enabled, Page Index Enabled)全件読み込み2.37s2.49s67,513,342行
3未ソート Parquet (Stats Enabled, Page Index Disabled)全件読み込み2.35s2.45s67,513,342行
4未ソート Parquet (Stats Disabled, Page Index Disabled)全件読み込み2.32s2.43s67,513,342行
5CSV (TIMESTAMPTZ)2025年4月抽出 (DATE_TRUNC, TZ)29.72s65.73s67,513,342行
6CSV (TIMESTAMP)2025年4月抽出 (DATE_TRUNC, +INTERVAL)19.46s65.14s67,513,342行
7CSV (TIMESTAMPTZ)2025年4月期間抽出 (BETWEEN, TZ)47.85s113.15s67,513,342行
8未ソート Parquet (Stats Enabled, Page Index Enabled)2025年4月抽出 (DATE_TRUNC, TZ)13.70s2.65s67,513,342行
9未ソート Parquet (Stats Enabled, Page Index Enabled)2025年4月抽出 (DATE_TRUNC, +INTERVAL)14.46s56.79s5,516,107行
10未ソート Parquet (Stats Enabled, Page Index Enabled)2025年4月期間抽出 (BETWEEN, TZ)20.19s2.81s67,513,342行
11未ソート Parquet (Stats Enabled, Page Index Enabled)2025年4月期間抽出 (BETWEEN, +INTERVAL)3.70s13.90s5,516,107行
12ソート済み Parquet (Stats Enabled, Page Index Enabled)2025年4月期間抽出 (BETWEEN, +INTERVAL)3.66s13.78s5,516,107行
13ソート済み Parquet (Stats Enabled, Page Index Enabled)2025年4月期間抽出 (カラム加工無し)0.296s0.35s5,516,111行
14未ソート Parquet (Stats Enabled, Page Index Enabled)2025年4月期間抽出 (カラム加工無し)0.268s0.31s5,516,111行
15未ソート Parquet (Stats Enabled, Page Index Disabled)2025年4月期間抽出 (BETWEEN, +INTERVAL)3.63s13.73s5,516,107行
16未ソート Parquet (Stats Disabled, Page Index Disabled)2025年4月期間抽出 (BETWEEN, +INTERVAL)3.65s13.76s5,516,107行
17未ソート Parquet (Stats Enabled, Page Index Disabled)2025年4月抽出 (DATE_TRUNC, TZ)13.66s2.70s67,513,342行
18未ソート Parquet (Stats Disabled, Page Index Disabled)2025年4月抽出 (DATE_TRUNC, TZ)13.60s2.66s67,513,342行
19ソート済み Parquet (Stats Disabled, Page Index Disabled)2025年4月期間抽出 (カラム加工無し)0.582s1.65s5,516,111行

Apache Parquetの特徴

こちらの記事で伝えたい内容に限定してお話すると書き込まれたFileMetaDataを読み取って必要になる “Row group” まで読み飛ばすことが出来ます。”Row group” の中には “Page” がありますがこの中でも読み飛ばすことができますがPage Index[3]が必要になります。

今回は全然カラム数ありませんしテキストもありませんが、大量のカラム数と文字列があるときはPage Index欲しいですね。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

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

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


採用情報
お問い合わせ