DuckDB-WasmでCSV・Parquetをブラウザ分析する|サーバーへ送らずSQL集計

ブラウザでSQL集計。DuckDB-WasmによるCSV・Parquet分析を深緑と黄緑の大きな文字で示したアイキャッチ ブラウザDB・ストレージ
カテゴリー
ブラウザDB・ストレージ
公開日
2026.09.22

はじめに

CSVファイルを集計するためだけに、サーバーへアップロードしたり、データベースへインポートしたりするのは大げさな場合があります。

DuckDB-Wasmを使うと、分析向けSQLエンジンであるDuckDBをWebAssemblyとしてブラウザ内で実行できます。CSV、JSON、Apache Arrow、Parquetなどを扱えるため、ローカルファイルを選び、その場でSQL集計できます。

この記事では、次の形式の売上ファイルをカテゴリ別に集計するアプリを作ります。

category,amount
書籍,1200
文房具,500
書籍,1800

完成するアプリはCSVとParquetに対応し、次を表示します。

  • カテゴリ名
  • 行数
  • 金額合計
  • 金額平均

DuckDB-Wasmが向いている処理

DuckDBはOLAP、つまり大量データの集計や分析を得意とするインプロセスデータベースです。

ブラウザ版でも、CSVやParquetをSQLで集計し、Apache Arrow形式の結果をJavaScriptへ返せます。

一方、1件ずつ頻繁に更新するTODOアプリの永続化にはSQLiteやIndexedDBの方が適する場合があります。DuckDB-Wasmの標準構成には、前の記事で扱ったSQLite OPFSのような永続DBを当然に期待しないでください。

制約を先に確認する

DuckDB-Wasm公式ドキュメントでは、次の制約が案内されています。

  • 標準では単一スレッド構成が選ばれる場合がある
  • WebAssemblyのメモリ上限があり、ブラウザ側の制限も受ける
  • ネイティブ版DuckDBのすべての機能が同じように使えるわけではない
  • HTTPで外部ファイルを読む場合はCORSの影響を受ける

数GBのファイルを選べるUIを作ったからといって、そのファイルを安全に処理できるとは限りません。アプリ側でもファイルサイズ上限を設け、対象端末で測定します。

プロジェクトを作成する

HTML・JavaScriptのファイルを保存し、ターミナルでコマンドを実行できる方向けです。Node.js 22.12以上(この例では24系)とnpm、Chromeなどのブラウザを用意してください。SQLは、表から必要な行を取り出したり集計したりするための言語です。以下では完成コードと集計結果を照合します。

mkdir duckdb-browser-analysis
cd duckdb-browser-analysis
npm init -y
npm install @duckdb/duckdb-wasm@1.32.0
npm install --save-dev vite@7.3.6

index.htmlmain.jsを作成します。

HTMLを作成する

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>DuckDB-Wasm売上集計</title>
    <style>
      body { width: min(860px, calc(100% - 32px)); margin: 40px auto; font-family: system-ui; }
      table { width: 100%; border-collapse: collapse; margin-top: 16px; }
      th, td { border: 1px solid #cbd5e1; padding: 8px; text-align: right; }
      th:first-child, td:first-child { text-align: left; }
      input { max-width: 100%; }
      .table-scroll { overflow-x: auto; }
      th, td { overflow-wrap: anywhere; }
    </style>
  </head>
  <body>
    <main>
      <h1>CSV・Parquet売上集計</h1>
      <p><code>category</code>列と<code>amount</code>列を持つファイルを選択してください。</p>
      <label for="file">分析するファイル</label>
      <input id="file" type="file" accept=".csv,.parquet,text/csv" />
      <button id="analyze" type="button">分析する</button>
      <p id="status" role="status" aria-live="polite">ファイルを選択してください。</p>
      <div class="table-scroll"><table>
        <thead><tr><th>カテゴリ</th><th>行数</th><th>合計</th><th>平均</th></tr></thead>
        <tbody id="results"></tbody>
      </table></div>
    </main>
    <script type="module" src="/main.js"></script>
  </body>
</html>

DuckDBを初期化する

main.jsへ次を記述します。

import * as duckdb from "@duckdb/duckdb-wasm";

const MAX_FILE_SIZE = 200 * 1024 * 1024;
const fileInput = document.querySelector("#file");
const analyzeButton = document.querySelector("#analyze");
const status = document.querySelector("#status");
const results = document.querySelector("#results");

let databasePromise;

async function createDatabase() {
  const bundles = duckdb.getJsDelivrBundles();
  const bundle = await duckdb.selectBundle(bundles);

  if (!bundle.mainWorker || !bundle.mainModule) {
    throw new Error("利用できるDuckDB-Wasmバンドルがありません。");
  }

  const workerUrl = URL.createObjectURL(
    new Blob([`importScripts("${bundle.mainWorker}");`], {
      type: "text/javascript",
    }),
  );

  let worker;
  let timer;
  let onWorkerError;
  try {
    worker = new Worker(workerUrl);
    const startupFailure = new Promise((_, reject) => {
      onWorkerError = () => reject(new Error("Workerを読み込めません。通信環境を確認してください。"));
      worker.addEventListener("error", onWorkerError, { once: true });
      timer = setTimeout(() => reject(new Error("初期化が60秒以内に完了しませんでした。通信環境を確認して再試行してください。")), 60_000);
    });
    const logger = new duckdb.ConsoleLogger(duckdb.LogLevel.WARNING);
    const db = new duckdb.AsyncDuckDB(logger, worker);
    await Promise.race([db.instantiate(bundle.mainModule, bundle.pthreadWorker), startupFailure]);
    return db;
  } catch (error) {
    worker?.terminate();
    throw error;
  } finally {
    clearTimeout(timer);
    if (onWorkerError) worker?.removeEventListener("error", onWorkerError);
    URL.revokeObjectURL(workerUrl);
  }
}

function getDatabase() {
  databasePromise ??= createDatabase().catch((error) => {
    databasePromise = undefined;
    throw error;
  });
  return databasePromise;
}

function getFileType(file) {
  const lowerName = file.name.toLowerCase();
  if (lowerName.endsWith(".csv")) return "csv";
  if (lowerName.endsWith(".parquet")) return "parquet";
  throw new Error("CSVまたはParquetファイルを選択してください。");
}

function createQuery(fileType) {
  const source = fileType === "csv"
    ? "read_csv_auto('input.csv', header = true)"
    : "read_parquet('input.parquet')";

  return `
    SELECT
      CAST(category AS VARCHAR) AS category,
      count(*) AS row_count,
      round(sum(try_cast(amount AS DOUBLE)), 2) AS total_amount,
      round(avg(try_cast(amount AS DOUBLE)), 2) AS average_amount
    FROM ${source}
    GROUP BY category
    ORDER BY total_amount DESC NULLS LAST
  `;
}

function renderRows(rows) {
  results.replaceChildren();

  for (const row of rows) {
    const tr = document.createElement("tr");
    const values = [
      row.category,
      String(row.row_count),
      row.total_amount == null ? "計算不可" : Number(row.total_amount).toLocaleString("ja-JP"),
      row.average_amount == null ? "計算不可" : Number(row.average_amount).toLocaleString("ja-JP"),
    ];

    for (const value of values) {
      const td = document.createElement("td");
      td.textContent = value;
      tr.append(td);
    }
    results.append(tr);
  }
}

async function analyze(file) {
  const fileType = getFileType(file);

  if (file.size === 0) throw new Error("ファイルが空です。");
  if (file.size > MAX_FILE_SIZE) {
    throw new Error("このサンプルでは200MBを超えるファイルを処理しません。");
  }

  const db = await getDatabase();
  const registeredName = fileType === "csv" ? "input.csv" : "input.parquet";
  const connection = await db.connect();

  try {
    await db.dropFiles();
    await db.registerFileHandle(
      registeredName,
      file,
      duckdb.DuckDBDataProtocol.BROWSER_FILEREADER,
      true,
    );

    const table = await connection.query(createQuery(fileType));
    return table.toArray().map((row) => row.toJSON());
  } finally {
    await connection.close();
    await db.dropFile(registeredName).catch(() => null);
  }
}

analyzeButton.addEventListener("click", async () => {
  const file = fileInput.files?.[0];
  if (!file) {
    status.textContent = "先にファイルを選択してください。";
    return;
  }

  analyzeButton.disabled = true;
  results.replaceChildren();
  status.textContent = "DuckDB-Wasmを準備して分析しています。";

  try {
    const rows = await analyze(file);
    renderRows(rows);
    status.textContent = `${rows.length}カテゴリを集計しました。`;
  } catch (error) {
    console.error(error);
    status.textContent = `分析に失敗しました: ${error.message}`;
  } finally {
    analyzeButton.disabled = false;
  }
});

初期化処理のポイント

端末に合うバンドルを選ぶ

DuckDB-WasmにはMVP、例外処理、Cross-Origin Isolation対応など複数のバンドルがあります。selectBundle()は利用可能なブラウザ機能を調べ、候補から選択します。

Worker URLを解放する

選択されたWorkerスクリプトをimportScripts()するBlob URLを作っています。Worker作成後にURL.revokeObjectURL()で不要なURLを解放します。

初期化Promiseを再利用する

WASMモジュールは大きいため、クリックのたびに新しいAsyncDuckDBを作らず、databasePromiseで再利用します。

Workerの読み込みエラーと60秒の初期化タイムアウトも監視します。失敗したWorkerを終了し、初期化Promiseを破棄することで、「準備中」のまま操作できなくなるのを避けます。60秒はこのサンプルの待ち時間で、DuckDBの仕様上の上限ではありません。

ファイルをDuckDBへ登録する

await db.registerFileHandle(
  "input.csv",
  file,
  duckdb.DuckDBDataProtocol.BROWSER_FILEREADER,
  true,
);

利用者が選択したFileを、DuckDB内の仮想ファイル名input.csvとして登録します。SQLはこの名前を参照します。

今回のコードではファイル名を利用者入力からSQLへ埋め込まず、拡張子に応じた固定値へ変換しています。

CSVとParquetを読み分ける

CSVはread_csv_auto()、Parquetはread_parquet()で読み込みます。

SELECT * FROM read_csv_auto('input.csv', header = true);
SELECT * FROM read_parquet('input.parquet');

CSVの自動型推定は便利ですが、データによって推定結果が変わる可能性があります。本番アプリでは列名と型を明示する、読み込み後にスキーマを検証する、日付形式を指定するといった対策を検討します。

try_castを使う理由

amount不明など数値でない値が混じると、通常のCASTはクエリ全体を失敗させます。

try_cast(amount AS DOUBLE)は変換できない値をNULLにします。アプリを停止させず集計できますが、問題行を黙って無視することにもなるため、実務では変換失敗件数を別に表示してください。

count(*) FILTER (WHERE try_cast(amount AS DOUBLE) IS NULL) AS invalid_amount_count

Apache Arrowの結果をJavaScriptへ変換する

connection.query()はApache ArrowのTableを返します。

const table = await connection.query(sql);
const rows = table.toArray().map((row) => row.toJSON());

64ビット整数がBigIntとして返る場合があります。JSON.stringify()はBigIntを直接処理できないため、画面表示やJSON化の前にString()または安全性を確認したNumber()へ変換します。

起動する

npx vite

表示されたローカルURL(通常はhttp://localhost:5173)をブラウザで開きます。HTMLファイルを直接ダブルクリックして開かないでください。終了するときは、サーバーを起動したターミナルでCtrl+Cを押します。

次の内容をUTF-8のテキストファイルsales.csvとして保存してください。拡張子が.csv.txtにならないようにします。画面でそのファイルを選び、「分析する」を押します。初回はエンジンのダウンロードを待ちます。

category,amount
書籍,1200
文房具,500
書籍,1800
食品,900

期待結果は次の3行です。合計の大きい順に並びます。

カテゴリ行数合計平均
書籍23,0001,500
食品1900900
文房具1500500

Parquetは型情報を持つ列指向のファイル形式です。試す場合はcategoryamount列を持つ本物のParquetファイルを選びます。CSVの拡張子だけを.parquetへ変更しても変換にはなりません。

なお、count(*)は金額が無効な行も数えますが、合計・平均はtry_cast()が返したNULLを除外します。行数と平均の分母が異なる場合がある点に注意してください。また、DOUBLEは浮動小数点数なので、この例を厳密な会計処理へそのまま使わず、金額の単位やDECIMAL型を別途設計してください。

よくあるトラブル

  • 初期化に失敗する:インターネット接続と、jsDelivrへの通信がブロックされていないか確認します。
  • 列が見つからない:CSVの先頭行にcategory,amountがあり、列名のつづりが一致しているか確認します。
  • 「計算不可」になる:そのカテゴリの金額がすべて空欄や数値以外になっていないか確認します。
  • 大きなファイルで動かない:200MBはこのサンプルの受付上限であり、動作保証ではありません。まず小さなデータへ減らしてください。

動作確認

正常系

  • UTF-8のCSVを読み込める
  • Parquetを読み込める
  • カテゴリごとの件数、合計、平均が正しい
  • 同じページで別ファイルを続けて分析できる
  • 接続と登録ファイルを処理後に解放する

エラー系

  • ファイル未選択では開始しない
  • 空ファイルを拒否する
  • 200MBを超えるファイルをこのサンプルでは拒否する
  • category列がない場合にSQLエラーを表示する
  • amountが数値化できない場合を確認する
  • 拡張子を偽装した壊れたParquetを正常扱いしない

セキュリティとプライバシー

このコードは利用者が選択したファイルを分析用サーバーへ送信しません。ただし、DuckDB-Wasm本体とWorker、WASMファイルはjsDelivrから取得する構成です。

完全オフラインや外部通信禁止が要件なら、必要ファイルを自サイトへ配置し、Content Security PolicyとNetworkパネルで通信を確認します。

また、選択ファイルの内容をログや解析サービスへ送らないよう、アプリ全体を確認してください。

SQLiteとの使い分け

用途向いている候補
CSV・Parquetの集計、分析SQLDuckDB-Wasm
小さなレコードの頻繁な追加・更新SQLite WASM
キーと値、Webオブジェクトの保存IndexedDB
ファイルベースのバイナリ保存OPFS

実際には組み合わせることもできます。例えばOPFSへ元ファイルを保存し、DuckDB-Wasmで分析する構成です。

関連記事

まとめ

DuckDB-Wasmを使うと、利用者が選んだCSVやParquetをブラウザ内でSQL集計できます。

実用化では、ファイルサイズ、型推定、無効値、BigInt、メモリ上限、外部バンドル取得を確認し、「ファイルを選択できた」だけで対応可能と判断しないことが重要です。

参考リンク

コメント

タイトルとURLをコピーしました