はじめに
BigQueryで分析をしていると、「横持ち(wide format)」で保存されているデータを「縦持ち(long format)」に変換したい場面によく出会います。例えば、月別売上が列として横に並んでいるデータをダッシュボード用に変換したり、リピート購買分析で複数の期間軸(3ヶ月以内・全期間など)を1つのカラムにまとめたりするケースです。
例えばECサイトのリピート購買分析で、複数の期間軸(3ヶ月以内・全期間など)を1つのカラムにまとめたいといったケースを想定し、代表的な3つの方法を実際に手を動かして比較してみます。
UNPIVOTUNION ALLCROSS JOIN UNNEST+STRUCT
環境設定
検証に使ったサンプルデータは以下のような横持ちのテーブルです。
| id | jan_sales | feb_sales | mar_sales |
|---|---|---|---|
| 1 | 100 | 200 | 150 |
| 2 | 300 | 0 | 80 |
これを、以下のような縦持ちに変換することが今回のゴールです。
| id | month | sales_amount |
|---|---|---|
| 1 | jan_sales | 100 |
| 1 | feb_sales | 200 |
| 1 | mar_sales | 150 |
| 2 | jan_sales | 300 |
| 2 | feb_sales | 0 |
| 2 | mar_sales | 80 |
実装手順
方法① UNPIVOT
BigQueryが標準SQLとして提供している専用構文です。
SELECT *
FROM sales_wide
UNPIVOT(
sales_amount FOR month IN (jan_sales, feb_sales, mar_sales)
)
列名をINの中に並べるだけで、対象列が自動的にmonth(列名)とsales_amount(値)の2列に展開されます。
方法② UNION ALL
一番古典的で、SQLを書いたことがある人なら誰でも読める方法です。
SELECT id, 'jan_sales' AS month, jan_sales AS sales_amount FROM sales_wide
UNION ALL
SELECT id, 'feb_sales', feb_sales FROM sales_wide
UNION ALL
SELECT id, 'mar_sales', mar_sales FROM sales_wide
対象の列ごとにSELECTを1本書き、UNION ALLで縦に積み上げていくイメージです。
方法③ CROSS JOIN UNNEST + STRUCT
配列とSTRUCTを組み合わせて、擬似的に横→縦変換をする方法です。
SELECT id, month, sales_amount
FROM sales_wide,
UNNEST([
STRUCT('jan_sales' AS month, jan_sales AS sales_amount),
STRUCT('feb_sales', feb_sales),
STRUCT('mar_sales', mar_sales)
])
STRUCTの配列をUNNESTで展開しつつ、元テーブルと暗黙的にCROSS JOINさせることで、月ごとの値を行に持たせています。
結果
3つの方法とも、狙い通り前述の縦持ちテーブルに変換できます。実行した上で確認できた、それぞれの良いところ・悪いところをまとめます。
方法① UNPIVOT
良いところ
- とにかく短い。列名を並べるだけで変換できる
- 意図が明確。「これはUNPIVOTしている」とコードを読んだ瞬間に分かる
- BigQuery側でクエリの最適化が効きやすい(専用構文のため)
悪いところ
- 列ごとに個別の加工(型変換、条件分岐、null埋めなど)を挟みたい場合には不向き
- 対象列がすべて同じデータ型である必要がある
- NULLの扱いに癖がある。デフォルトでは値がNULLの行も出力される(除外したい場合は別途WHEREが必要)
- 列名の数が多い、あるいは動的に変わる場合は事前に列名をすべて書き出す必要があり、メンテナンス性が下がる
方法② UNION ALL
良いところ
- 列ごとに個別のロジックを差し込める(例:2月だけ集計方法が違う、特定の列だけ丸め処理をする、など)
- どんなSQLエンジンでも動く汎用性の高さ。BigQuery特有の構文に依存しない
- 一行ずつ読めば処理内容が追える(可読性はUNPIVOTに劣るが、複雑な分岐を書く場合はこちらの方が結局読みやすいことも多い)
悪いところ
- 列数が増えるとクエリが縦に伸びて冗長になる。10列あれば10個のSELECTが並ぶ
- 元テーブルを列数分スキャンすることになるため、大きいテーブルではコスト・パフォーマンス面で不利になりやすい
- 列名の追加・変更のたびにクエリ全体を書き換える必要がある
方法③ CROSS JOIN UNNEST + STRUCT
良いところ
- 月ごとに複数の付随情報(例:目標値、前年同月比など)をまとめて持たせられる。STRUCTの中にフィールドを増やすだけでよい
- 配列操作(WITH OFFSETなど)と組み合わせて、順序情報を持たせることも可能
- UNPIVOTより自由度が高く、UNION ALLより行数を圧縮できる
悪いところ
- 構文がやや独特で、UNPIVOTやUNION ALLに比べて初見の人には読みにくい
- STRUCTの記法(1つ目だけフィールド名を書けば以降は型推論される、など)に慣れていないとミスしやすい
- 単純な横→縦変換だけが目的なら、オーバースペックになりがち
3つの比較まとめ
| 観点 | UNPIVOT | UNION ALL | UNNEST + STRUCT |
|---|---|---|---|
| 記述量 | 少ない | 多い(列数分) | 中程度 |
| 可読性 | 高い(意図が明確) | 中程度(単純な分だけ追いやすい) | やや低い(構文に慣れが必要) |
| 列ごとの個別加工 | しにくい | しやすい | しやすい |
| 複数の付随情報を持たせる | 不可 | 不可(列を増やす必要あり) | 得意 |
| パフォーマンス | 良い | 列数分スキャンしがちで不利 | 中程度 |
| 汎用性(他エンジンでも使えるか) | BigQuery固有構文 | 高い(標準SQL) | BigQuery固有の書き方 |
まとめ
リピート購買分析(3ヶ月/全期間)のように、「軸となる列が少数で、加工ロジックがシンプル」な場合はUNPIVOTが最適そうです。コードの見通しが良く、後から見た人にも一目で意図が伝わります。
逆に、月別売上のように列が多く、かつ列ごとに前年同月比や目標達成率といった複数の付随情報を持たせたい場合は、UNNEST+STRUCTの方が構造として無理がありません。UNION ALLは正直なところ、他の2つの方法でカバーしきれない特殊な分岐処理(例:特定の月だけ税抜き計算が必要、など)がある場合の最終手段として使うことが多いです。
「横→縦変換」は地味な処理に見えて、選ぶ手法次第でクエリの可読性・保守性・コストが結構変わってきます。まずはUNPIVOTで書けないか検討し、それで表現しきれない要件が出てきたときにUNION ALLかUNNEST+STRUCTを検討する、という順番がおすすめです。

