先に結論です。DuckDB にファイルを直接指定してください。
duckdb -c "SELECT region, SUM(amount) FROM 'sales.csv' GROUP BY region"
スキーマもインポートもサーバーも CREATE TABLE も要りません。CSV はそのままの場所に置いたままで、ヘッダー行がそのまま列名になります。今日ひとつだけ入れるなら、これを入れてください。
このページの残りでは、なぜこれで動くのか、DuckDB が使えないときはどうするか、そして誰もがつまずく 2 点、ファイル名の引用符と、列名にスペースが入っている場合の扱いを説明します。
ファイルをどこかに読み込む必要がない理由
「まずデータをデータベースに入れる」という発想は、SQL エンジンが自分のストレージしか読めなかった時代の名残です。最近のエンジンにはその制約がありません。DuckDB、SQLite、いくつかの小さな CLI ツールは、1 回のクエリの間だけ CSV をテーブルとして扱い、サンプリングで型を推定して結果を返し、あとはすべて忘れます。
これで損得の計算がまるで変わります。メールで届いたファイルについての単発の質問に、スキーマも読み込みスクリプトも後片付けも割く価値はありません。1 行で十分です。
本物のデータベースに読み込むのが正解になるのは、次の 2 つの場合だけです。複数人が繰り返しデータを問い合わせる場合と、1 枚のフラットなファイルでは表現できない制約やリレーションが必要な場合です。「このエクスポートの地域別合計は?」はどちらにも当てはまりません。
選択肢を正直に比較
| ツール | インストール | 向いている用途 | 苦手なこと |
|---|---|---|---|
| DuckDB | バイナリ 1 つ、依存なし | 数十 GB までのほぼすべて | この用途ではほぼなし |
SQLite + .import | macOS と Linux に標準搭載 | テーブルを後のために残す | 型。すべて TEXT になる |
q | pip install q-text-as-data | シェルのパイプラインでの手早いワンライナー | 大きなファイル。遅い |
csvsql (csvkit) | pip install csvkit | 本物の DB 用の CREATE TABLE を生成する | 速度。ここで一番遅い |
Pandas + pandasql | pandas が既にある | 結果をさらに Python で使うとき | SQL の方言が Postgres ではなく SQLite |
| SQL が使えるスマホアプリ | App Store / Play | ファイルがスマホのメールに届いたとき | スクリプト化したいこと全般 |
正直なランキングは短く、ほとんどは DuckDB、何も入れられないときは SQLite、パソコンの前にいないときはスマホアプリです。他のツールも悪くなく出番はありますが、この用途で DuckDB に勝るものはありません。
DuckDB の使い方
インストールはバイナリ 1 つです。brew install duckdb か、ダウンロードしてパスの通った場所に置いてください。そのあとは次のようにします。
duckdb
D SELECT * FROM 'orders.csv' LIMIT 10;
最初に知っておくことが 2 つあります。
ファイル名はシングルクォートで囲みます。 FROM orders.csv は、スキーマ orders とテーブル csv として解釈され、エラーメッセージを見ても原因が分かりにくいです。正しくは FROM 'orders.csv' です。
スペースを含む列名はダブルクォートで囲みます。 SELECT "Order Date" FROM … のようにします。ここでシングルクォートを使うと、エラーにならず、全行に同じ文字列がそのまま返るため、クエリが成功したように見えてしまいます。
ディレクトリごとまとめて読むには glob を使います。エクスポートが月ごとに分かれて届いたときに便利です。
SELECT * FROM 'exports/*.csv'
型の推定が外れたときは、たいてい先頭にゼロが付く ID の列や、認識できない形式の日付が原因です。そのときは推定と戦わず、読み込みを自分で制御してください。
SELECT * FROM read_csv('orders.csv',
header = true,
columns = {'id': 'VARCHAR', 'ordered_at': 'DATE', 'amount': 'DOUBLE'},
dateformat = '%d/%m/%Y'
)
read_csv には delim、quote、escape、ignore_errors = true、sample_size = -1 も指定できます。sample_size = -1 は、先頭 20,480 行ではなくファイル全体をスキャンしてから型を決めます。1 万行ほど数値に見えた列が、途中で N/A に当たるときの対処がこれです。
結果をファイルに書き戻すのも SQL のままでできます。
COPY (SELECT * FROM 'orders.csv' WHERE amount > 1000)
TO 'large-orders.csv' (HEADER, DELIMITER ',');
同じファイルを数回以上問い合わせるなら、COPY (SELECT * FROM 'orders.csv') TO 'orders.parquet' で一度 Parquet に変換し、以後は Parquet に問い合わせてください。型が付き、圧縮され、スキャンは通常 5 倍から 20 倍速くなります。
SQLite:何もインストールできないとき
sqlite3 は macOS とほとんどの Linux ディストリビューションに入っているので、制限の厳しいマシンでも使えます。
sqlite3 shop.db
sqlite> .mode csv
sqlite> .import orders.csv orders
sqlite> SELECT region, COUNT(*) FROM orders GROUP BY region;
注意点があり、これは重要です。.import はすべての列を TEXT にします。 そのため並べ替えが辞書順になり、100 が 20 より前に来て、SUM() も意外な結果になります。クエリ時にキャスト(CAST(amount AS REAL))するか、インポート前に正しい型でテーブルを作成してください。
DuckDB と違い、shop.db が残ります。明日もそのテーブルを使いたいなら利点ですが、そうでなければ余計なファイルです。
q:シェルのパイプライン向け
クエリがシェルコマンドの連なりの一段階なら、q は他の Unix ツールと同じように標準入力を読みます。
q -H -d, "SELECT region, SUM(amount) FROM - GROUP BY region" < orders.csv
-H は 1 行目がヘッダーであること、-d, は区切り文字、- は標準入力を表します。中身は SQLite なので、方言も SQLite です。大きなファイルでは、現実的な選択肢の中で一番遅く、数十万行なら問題ありませんが、それ以上は厳しくなります。
ファイルがスマホにあるとき
エクスポートがメールの添付ファイルで、デスクにいない場合、ここまでの方法は役に立ちません。よくある状況で、「自分宛てに送って後で見る」という通常の対処は、あまり良い方法ではありません。
iOS と Android でファイルに対して本物の SQL を実行できます。スマホのキーボードでクエリを打ちたくないときのためにビジュアルなフィルタービルダーがあり、列名がまだ分からないときのためにふつうの言葉で聞けるアシスタントもあります。
できること方言は SQLite なので、この記事のクエリはそのまま移せますが、いつもの注意は必要です。DuckDB の例にあるウィンドウ関数は使えず、CAST の習慣も同じように必要です。
手元に置いておきたいクエリ
このファイルに何が入っているか:
SELECT * FROM 'file.csv' LIMIT 20;
DESCRIBE SELECT * FROM 'file.csv';
SELECT COUNT(*) FROM 'file.csv';
NULL がどこにあるか。 列を 1 つずつ調べるのは面倒なので、こうします。
SELECT COUNT(*) - COUNT(email) AS missing_email,
COUNT(*) - COUNT(phone) AS missing_phone
FROM 'contacts.csv';
キーの重複:
SELECT id, COUNT(*) AS n
FROM 'orders.csv'
GROUP BY id HAVING n > 1
ORDER BY n DESC;
2 つのエクスポートの結合は、この方法の価値を一番よく示すクエリです。2 つのシートをまたいで VLOOKUP でやるよりずっと楽だからです。
SELECT o.id, o.amount, c.name
FROM 'orders.csv' o
JOIN 'customers.csv' c ON c.id = o.customer_id;
日付列から月別の合計:
SELECT date_trunc('month', ordered_at) AS month, SUM(amount)
FROM 'orders.csv'
GROUP BY month ORDER BY month;
うまくいかないとき
すべてが 1 列にまとまって返ってきた。 区切り文字がカンマではありません。ヨーロッパの多くの地域ではセミコロンが標準です。read_csv('f.csv', delim = ';') を試してください。それ以上に変なファイルなら、単に珍しいのではなく、本当に壊れているのかもしれません。
数値の列が合計できない。 非数値が混ざっているため、テキストとして推定されています。原因は SELECT amount FROM 'f.csv' WHERE TRY_CAST(amount AS DOUBLE) IS NULL LIMIT 10; で見つけられます。通貨記号、桁区切り、N/A という文字列のどれかであることがほとんどです。
先頭のゼロが消えた。 列が数値として読まれています。columns で VARCHAR を指定し、その後は表計算ソフトで開かないでください。
ファイルの途中にヘッダー行が何度も出てくる。 複数のエクスポートが連結されています。WHERE id <> 'id' が荒っぽい対処ですが、たいていはこれで正解です。
メモリが足りなくなった。 DuckDB はストリーミングで処理するので、通常はファイル全体に対する ORDER BY や DISTINCT が原因です。一時ディレクトリ(SET temp_directory = '/tmp/duckdb')を指定すれば、ディスクにあふれさせて処理します。規模ごとに何が使えるかという広い話は、1GB の CSV ファイルを開く方法を参照してください。