tech-book-labs
データ基盤(クライアント完結) · 最終検証 2026-07-26 · @duckdb/duckdb-wasm 1.33.x · 初公開 2026-07-26

DuckDB-Wasm で GTFS(公共交通時刻表)をブラウザ分析する

公共交通の標準フォーマット GTFS(stops / trips / stop_times の CSV 群)を DuckDB-Wasm に読み込み、発車本数・次の発車・駅間所要時間をブラウザ内 SQL で集計するパターン。registerFileText での仮想ファイル登録、24 時超え時刻('25:30:00')を TIME にキャストできない罠、window 関数での駅間計算まで、架空 2 路線の触れる demo 付きで整理する実装メモ。

duckdb wasm gtfs sql open-data transit

検証日: 2026-07-26

使用バージョン: @duckdb/duckdb-wasm@1.33.1-dev45.0

対象: オープンデータの時刻表を「サーバーもローカル環境構築もなしで」触ってみたい人。CSV 群を SQL で JOIN したい場面全般

GTFS(General Transit Feed Specification — 世界中の公共交通機関が時刻表・停留所を公開するのに使う標準フォーマット)は、実体が zip に入った複数の CSV なので、DuckDB-Wasm と極めて相性がいい。この記事では GTFS の主要 4 ファイルをブラウザ内の DuckDB に読み込み、「駅別の発車本数」「次の発車」「駅間所要時間」を SQL だけで出す。動く demo 付き(データは架空 2 路線)。

DuckDB-Wasm 自体の導入(bundle 選択・COOP/COEP・メモリ上限)は前の記事で扱ったので、この記事は「実データ形式を食わせる」側に集中する。

触って試す

プリセットの 3 クエリはそのまま実行でき、SQL は自由に書き換えられる。データは架空の 2 路線(本線 8 駅・支線 4 駅、朝 6:00〜9:00 のダイヤ約 350 発車)だが、列構成は本物の GTFS と同一なので、クエリは実データにそのまま流用できる。

1. GTFS の中身 — 4 テーブルの関係

GTFS zip の中で分析によく使うのは次の 4 ファイル。リレーショナルな構造がそのまま SQL の JOIN に対応する:

ファイル中身主キー
stops.txt停留所・駅(名前、緯度経度)stop_id
routes.txt路線(名前、種別 = 鉄道/バス等)route_id
trips.txt便(「7:00 発の下り」のような 1 運行)trip_id
stop_times.txt便がどの停留所に何時に着くかtrip_id + stop_sequence

関係は routes 1—n trips 1—n stop_times n—1 stops。**行数の主役は stop_times**で、都市圏の実フィードでは数十万〜数百万行になる — ブラウザ内で列指向エンジンの DuckDB を使う意味はここにある。

2. 最小サンプル — CSV 文字列を仮想ファイルとして登録する

DuckDB-Wasm には「ブラウザのメモリ上の文字列/バッファをファイルとして見せる」API がある。次のコードは CSV 文字列を登録してテーブル化する:

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

// (bundle 選択〜 instantiate は導入記事の通り)
const conn = await db.connect();

// 文字列を仮想ファイル 'stops.txt' として登録
await db.registerFileText("stops.txt", stopsCsv);

// GTFS の時刻は '25:30:00' がありうるので、まず全列 VARCHAR で読む(§4)
await conn.query(`
  CREATE TABLE stops AS
  SELECT * FROM read_csv_auto('stops.txt', all_varchar=true);
`);

ファイルの入手経路ごとに使う API が変わる:

  • 文字列(fetch 済み・埋め込み)db.registerFileText(name, text)
  • URL から直接db.registerFileURL(name, url, duckdb.DuckDBDataProtocol.HTTP, false)(CORS 許可が必要)
  • ユーザがドロップした Filedb.registerFileHandle(name, file, duckdb.DuckDBDataProtocol.BROWSER_FILEREADER, true)

zip の展開は DuckDB ではできないので、実フィードは fetch 後に zip.js 等で展開してから registerFileText する。

3. 定番クエリ 3 つ

(1) 駅別の発車本数 — JOIN + GROUP BY の素直な形:

SELECT s.stop_name, count(*) AS departures
FROM stop_times st JOIN stops s USING (stop_id)
GROUP BY s.stop_name
ORDER BY departures DESC;

(2) ある駅の「次の 5 本」 — GTFS の時刻は文字列だが、HH:MM:SS 固定幅なので文字列比較がそのまま時刻順になる:

SELECT st.departure_time, r.route_short_name
FROM stop_times st
JOIN trips t USING (trip_id)
JOIN routes r USING (route_id)
JOIN stops s USING (stop_id)
WHERE s.stop_name = '中央' AND st.departure_time >= '07:30:00'
ORDER BY st.departure_time
LIMIT 5;

(3) 駅間の所要時間 — 「同じ便の次の停車」は window 関数 lead()(現在行から見て次の行の値を返す)で取る。時刻はまず分に変換しておく(理由は §4):

WITH st AS (
  SELECT trip_id, stop_id, stop_sequence::INT AS seq,
         CAST(split_part(departure_time, ':', 1) AS INT) * 60
       + CAST(split_part(departure_time, ':', 2) AS INT) AS dep_min
  FROM stop_times
), hop AS (
  SELECT trip_id, stop_id,
         lead(stop_id) OVER w AS next_stop,
         lead(dep_min) OVER w - dep_min AS minutes
  FROM st
  WINDOW w AS (PARTITION BY trip_id ORDER BY seq)
)
SELECT a.stop_name AS from_stop, b.stop_name AS to_stop, avg(minutes) AS avg_minutes
FROM hop JOIN stops a ON hop.stop_id = a.stop_id
         JOIN stops b ON hop.next_stop = b.stop_id
GROUP BY 1, 2;

PARTITION BY trip_id で便ごとに区切り、ORDER BY seq(停車順)で並べる — この 2 つが GTFS 分析の window 関数の型。

4. GTFS 特有の罠: 24 時を超える時刻

GTFS の時刻列は「サービス日基準」で、終電が日をまたぐと 25:30:00 のような表記になる(仕様で明示的に許可されている)。これを read_csv_auto の型推論に任せると 2 通りの事故が起きる:

  • 深夜便を含むフィード → 25:30:00 が TIME としてパースできず 読み込み自体が失敗
  • 深夜便を含まない部分だけ試して TIME 型になった → 後から実フィードを入れた時だけ落ちる

対処は「まず all_varchar=true で全部文字列として読む」に倒すこと。時刻順の比較・並べ替えは固定幅文字列のままで正しく動く(§3-(2))。分単位の計算が必要な箇所だけ、§3-(3) の split_part 方式で自前変換する — '25:30:00' もそのまま 1530 分になり、日またぎの減算も破綻しない。

5. 実データの入手先

架空データで型を掴んだら、実フィードに差し替える:

  • gtfs-data.jp — 日本の GTFS-JP フィードのカタログ。API(api.gtfs-data.jp/v2/feeds)でフィード一覧を取得でき、多くが登録不要でダウンロードできる(大半はバス事業者)
  • 公共交通オープンデータセンター(ODPT) — 首都圏の鉄道・地下鉄・バス。開発者登録(無料)が必要
  • Mobility Database — 世界のフィードカタログ

ライセンスはフィードごとに異なる(CC BY が多い)。可視化や分析結果を公開する場合は各フィードの出典表記に従う

つまずいたポイント

  • Invalid Input Error: Could not parse string "25:30:00": §4 の 24 時超え時刻。all_varchar=true で読み、時刻計算は split_part で自前変換
  • Binder Error: No function matches '-(TIME, TIME)': TIME 同士の減算がサポート外(DuckDB-Wasm 1.33 系で確認)。::TIME に頼らず分に変換してから引く(§3-(3) の形)
  • stop_sequence の並びが 1, 10, 11, 2, …: 全列 VARCHAR で読んでいるので文字列ソートになる。ORDER BY stop_sequence::INT と明示キャストする
  • JOIN 結果が 0 行: stop_id の前後空白。フィードによっては CSV に BOM や空白が混ざる。read_csv_auto は BOM は処理するが、空白は trim(stop_id) で正規化してから JOIN する
  • registerFileURLFailed to fetch: 配信元が CORS 未対応。ブラウザから直接読めるのは CORS ヘッダ付きの配信元だけなので、ダメなら一度 fetch できる場所(自サイトの public/ 等)に置く
  • BigInt が JSON.stringify で落ちる: count(*) の結果は BigInt で返る。表示前に Number(v) するか toString() する(demo は String(v) で統一)

向くケース / 向かないケース

判定
フィードの中身を対話的に調べる・可視化の前処理◎ 環境構築ゼロで JOIN・window 関数まで使える
「動く時刻表ビューア」をサーバーレスで公開◎ 静的ホスティング + フィード zip だけで成立
全国規模・複数フィードの横断バッチ集計△ ローカルの DuckDB(CLI/Python)の方が速くて安い
リアルタイム位置情報(GTFS-RT)✖ GTFS-RT は Protocol Buffers。別途デコーダが必要

関連書籍

tech-book.net /books/9784873118086

Python と JavaScriptではじめるデータビジュアライゼーション | tech-book.net

Kyran Dale/嶋田 健志/木下 哲也

関連テーマ・学習ロードマップから次の一冊へ。

詳細を tech-book.net で見る
tech-book.net /books/9784297129163

TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発 | tech-book.net

手島 拓也/吉田 健人/高林 佳稀

関連テーマ・学習ロードマップから次の一冊へ。

詳細を tech-book.net で見る