BigQuery ハンズオン教科書|2026-08 版
第 5 章 サンドボックスで完走可

ネスト・配列・JSON — BigQuery 流のデータモデリング

STRUCT と ARRAY を UNNEST で自在に展開し、非正規化スキーマを使いこなす

この章で学ぶこと

前提: サンドボックスで完走可

なぜ BigQuery では非正規化が有利なのか

これまでの章では、テーブルの列はすべて単純型(STRING や INT64 など)でした。しかし BigQuery では、列そのものが「入れ子のレコード」や「繰り返しの値」を持てます。これが GoogleSQL の STRUCT(構造体)と ARRAY(配列)です。リレーショナル DB の経験があると、1 対多の関係は親テーブルと子テーブルに分けて JOIN で結合するのが自然に思えますが、BigQuery ではむしろ「子テーブルの行を親テーブルの 1 列に埋め込んでしまう」非正規化が推奨されます。

理由はデータ調整のコストです。JOIN はキーの一致するデータをノード間で突き合わせる処理で、内部的にシャッフル(ノード間でのデータ再配置)が発生し、通信帯域が消費されます。関連するデータをあらかじめ 1 行の中に持たせておけば、この突き合わせ自体が不要になります。BigQuery の非正規化ベストプラクティスの解説では、Stack Overflow の公開データセットを使った検証で、フラットな構造(JOIN + GROUP BY)が約 25 秒・1.88 GB の処理に対し、同じ内容をネスト構造で持たせたテーブルでは約 10 秒・1.28 GB まで短縮されたと報告されています。約 60% の高速化、約 32% のデータ削減です。

正規化 + JOIN orders order_items JOIN シャッフル 結果 非正規化(ネスト) orders(items 内包) JOIN 不要 結果
図 5-1 正規化 + JOIN はノード間のシャッフルを伴うが、非正規化(ネスト)は 1 行を読むだけで同じ情報が得られる。

ただしすべてのケースで非正規化が勝つわけではありません。同じ解説では「スタースキーマはすでに最適化されたスキーマであり、さらに非正規化してもパフォーマンスが大きく改善しない場合がある」とも注意されています。子側の件数が非常に多く、独立して更新・参照されるならテーブルを分けたままの方が扱いやすいこともあります。

実務の視点: 非正規化を選ぶ基準は「子側の件数」と「常に親と一緒に読まれるか」です。1 件の注文に対する明細が数件〜数百件程度で、注文単位の分析が主な用途なら ARRAY 化が有利です。逆に明細が独立して大量に更新されたり、明細単体での検索が主用途なら、素直に別テーブルにして JOIN する方が運用しやすくなります。

STRUCT と ARRAY の宣言・参照

ネスト・繰り返しフィールドは、テーブルのスキーマでは「RECORD 型」「REPEATED モード」として表現されますが、GoogleSQL からは STRUCT 型・ARRAY 型としてアクセスします。

CREATE TABLE handson_ch05.orders (
  order_id INT64,
  customer STRUCT<name STRING, email STRING>,
  items ARRAY<STRUCT<item STRING, quantity INT64, unit_price INT64>>
);

STRUCT は角括弧ではなく <>(山括弧)で型を宣言し、フィールド名は省略できます。ARRAY は「同じ型の値の順序付きリスト」で、要素が STRUCT でもかまいません。ここで組んだ ARRAY<STRUCT<...>> は、1 対多の関係を 1 列に埋め込む定番パターンです。フィールドへのアクセスはドット記法(customer.name)で行います。なお、配列の配列は作れません。ネストした配列が必要な場合は、間に STRUCT を 1 段挟みます。ネストの深さにも上限があり、RECORD のネストは 15 段階まで(16 段階以上は不可)です。

order_id customer STRUCT items ARRAY<STRUCT>(繰り返し) STRUCT name: 田中 太郎 email: tanaka@... ARRAY(繰り返し) item: ノート PC quantity: 2 unit_price: 128000 item: マウス quantity: 1 unit_price: 3200
図 5-2 customer は 1 件の STRUCT、items は複数の STRUCT を保持する ARRAY(繰り返しフィールド)として 1 行に収まっている。

配列を直接操作する関数も豊富です。よく使うものを次に挙げます。

関数用途NULL・範囲外の扱い
ARRAY_LENGTH(arr)要素数を返すarr が NULL なら NULL
ARRAY_AGG(expr)複数行の値を 1 つの配列に集計する順序は保証されないため ORDER BY を明示する
arr[OFFSET(n)]0 始まりの位置で要素を取得範囲外はエラー
arr[SAFE_OFFSET(n)]0 始まりの位置で要素を取得範囲外は NULL
arr[ORDINAL(n)] / arr[SAFE_ORDINAL(n)]1 始まりの位置で要素を取得SAFE 版は範囲外で NULL
ARRAY_TO_STRING(arr, sep)配列を区切り文字で 1 本の文字列にする既定では NULL 要素を無視する

UNNEST の仕組み — 相関 CROSS JOIN と LEFT JOIN UNNEST

ARRAY 列そのままでは、要素ごとに集計したり条件を付けたりできません。そこで使うのが UNNEST です。UNNEST は配列を「複数行のテーブル」に変換するテーブル値関数で、次のように書きます。

SELECT order_id, customer.name, i.item, i.quantity
FROM handson_ch05.orders, UNNEST(items) AS i;

この FROM table, UNNEST(table.array_column) AS elem という形は、実質的に「各行に対して、その行が持つ配列だけを CROSS JOIN する」という動作をします。これを相関 CROSS JOINと呼びます。同じ意味を CROSS JOIN UNNEST(items) AS i と明示的に書いても構いません。

展開前(1 行) order_id: 1001 items: [明細A, 明細B] UNNEST(items) 展開後(2 行) order_id: 1001, item: 明細A order_id: 1001, item: 明細B
図 5-3 UNNEST は配列の要素数だけ行を複製し、それぞれに 1 要素をひも付けて展開する。

ここで注意が必要なのが、配列が空([])または NULL の行の扱いです。CROSS JOIN UNNEST は INNER JOIN と同じ理屈で動くため、展開対象の配列が空の行は結果からまるごと消えます。この行も残したい場合は LEFT JOIN UNNEST を使います。展開列は NULL になりますが、元の行自体は 1 行として保持されます。

-- 空配列の行を落とさない
SELECT order_id, customer.name, i.item, i.quantity
FROM handson_ch05.orders
LEFT JOIN UNNEST(items) AS i;
1001: [A, B] 1002: [] CROSS JOIN UNNEST 1001, A 1001, B 1002 は現れない LEFT JOIN UNNEST 1001, A 1001, B 1002, item: NULL
図 5-4 CROSS JOIN UNNEST は order 1002(空配列)を落とすが、LEFT JOIN UNNEST は item が NULL の行として残す。
実務の視点: 「注文ごとの明細数」を集計するようなクエリで CROSS JOIN UNNEST をうっかり使うと、明細 0 件の注文が母数から静かに消え、集計結果が実態より小さく出ます。COUNT や SUM の分母に注文全体を含めたいときは、まず LEFT JOIN UNNEST を既定にしておくと事故を防げます。

JSON 型と STRUCT の使い分け

ネスト構造をスキーマ側で持つ STRUCT に対し、BigQuery には JSON 型という選択肢もあります。JSON 型はスキーマ・オン・リードで扱えるデータ型で、列を作る際に内部構造を確定させる必要がありません。

CREATE TABLE handson_ch05.events (
  event_id INT64,
  payload JSON
);

STRUCT はフィールド名と型をスキーマとして固定するため、型チェックが効き、列指向ストレージの恩恵(使わないフィールドを読み飛ばす)を最大限に活かせます。一方 JSON 型は構造が事前に決まっていない、あるいは頻繁に変わるデータ(外部 API のレスポンスをそのまま保存する場合など)に向きます。

JSON の値を取り出す関数は複数あり、用途で使い分けます。JSON_VALUE はスカラー値を SQL の STRING に変換して返し、非スカラー(オブジェクトや配列)が指定された場合は NULL になります。JSON_QUERY は JSON 値のまま返すため、ネストしたオブジェクトや配列も取り出せます。配列専用には JSON_VALUE_ARRAY(スカラー配列を ARRAY<STRING> に)、JSON_QUERY_ARRAY(JSON の配列を ARRAY<JSON> に)があります。

型があいまいな JSON 値を柔軟に SQL 型へ変換したいときは LAX 系関数(LAX_BOOL / LAX_INT64 / LAX_STRING / LAX_FLOAT64 など)を使います。通常の CAST と異なり、変換できない場合はエラーではなく NULL を返すのが特徴です。例えば LAX_INT64(JSON '"42"') は文字列 "42" から整数 42 を返します。

スキーマは事前に確定し 安定しているか? Yes No STRUCT 固定スキーマ・型チェック JSON スキーマ・オン・リード
図 5-5 構造が安定しているかどうかが STRUCT と JSON 型を分ける判断軸になる。
実務の視点: JSON 型を選ぶと型安全性と読み取り性能の一部を手放す代わりに、フィールド追加のたびに ALTER TABLE しなくて済みます。頻繁にフィールドが増減するログ系データは JSON、分析の主軸になる安定したマスタ・トランザクションデータは STRUCT、というのが実務での定石です。
ハンズオン 5-1|STRUCT/ARRAY リテラルで非正規化テーブルを作り UNNEST で展開する

所要時間の目安: 5 分。サンドボックスで可・無料枠内(作成するテーブルは数百バイト程度)。

  1. データセット handson_ch05(ロケーション US)を作成します。コンソールの場合はエクスプローラの「データセットを作成」から、bq の場合は次のコマンドです。
bq mk --dataset --location=US PROJECT_ID:handson_ch05

PROJECT_ID は自分のプロジェクト ID に置き換えます。

  1. customer を STRUCT、items を ARRAY<STRUCT> にしたテーブルを CTAS(CREATE TABLE AS SELECT)で作成します。2 件目はあえて明細が 0 件の注文にします。
CREATE TABLE handson_ch05.orders AS
SELECT
  1001 AS order_id,
  STRUCT('田中 太郎' AS name, 'tanaka@example.com' AS email) AS customer,
  [STRUCT('ノート PC' AS item, 2 AS quantity, 128000 AS unit_price),
   STRUCT('マウス' AS item, 1 AS quantity, 3200 AS unit_price)] AS items
UNION ALL
SELECT
  1002,
  STRUCT('鈴木 花子', 'suzuki@example.com'),
  CAST([] AS ARRAY<STRUCT<item STRING, quantity INT64, unit_price INT64>>);
  1. UNNEST で明細行に展開します。
SELECT order_id, customer.name AS customer_name, i.item, i.quantity, i.unit_price
FROM handson_ch05.orders, UNNEST(items) AS i
ORDER BY order_id;

期待される出力(order_id 1002 は明細が無いため現れません):

order_id | customer_name | item      | quantity | unit_price
1001     | 田中 太郎      | ノート PC  | 2        | 128000
1001     | 田中 太郎      | マウス     | 1        | 3200

後片付け: ハンズオン 5-3 まで同じデータセットを使うため、ここでは削除しません。

ハンズオン 5-2|公開データセットの ARRAY<STRUCT> を UNNEST してリポジトリの言語構成を集計する

所要時間の目安: 5 分。サンドボックスで可・無料枠内(bigquery-public-data.github_repos.languages は約 195 MiB。列を絞ってもフルスキャンになりますが無料枠 1 TiB には十分収まります)。

github_repos.languages は repo_name STRING, language ARRAY<STRUCT<name STRING, bytes INT64>> という、1 リポジトリに対して複数の言語構成を持つ典型的なネストテーブルです。まず dry run でスキャン量を確認します。

bq query --use_legacy_sql=false --dry_run '
SELECT repo_name, language
FROM `bigquery-public-data.github_repos.languages`
WHERE repo_name = "torvalds/linux"'

続けて UNNEST で言語ごとの行に展開し、バイト数の多い順に並べます。

-- 処理: 約 195 MiB(列を絞ってもテーブル全体をスキャンする)
SELECT repo_name, lang.name AS language, lang.bytes
FROM `bigquery-public-data.github_repos.languages`,
UNNEST(language) AS lang
WHERE repo_name = 'torvalds/linux'
ORDER BY lang.bytes DESC
LIMIT 5;

実行すると、C や Makefile など、Linux カーネルらしい言語が上位に並びます(構成比はリポジトリの更新により変動するため、具体的な数値はここでは示しません)。全リポジトリを対象に「言語別の総リポジトリ数」を集計することもできます。

SELECT lang.name AS language, COUNT(DISTINCT repo_name) AS repo_count
FROM `bigquery-public-data.github_repos.languages`,
UNNEST(language) AS lang
GROUP BY language
ORDER BY repo_count DESC
LIMIT 10;

後片付け: 公開データセットを読んだだけなので後片付けは不要です。

ハンズオン 5-3|CROSS JOIN UNNEST と LEFT JOIN UNNEST の違いを確認する

所要時間の目安: 3 分。サンドボックスで可・無料枠内(数百バイトの自作テーブルへの参照)。

  1. ハンズオン 5-1 で作った handson_ch05.orders に対し、まず CROSS JOIN UNNEST(既定の UNNEST の書き方と同じ動作)で注文件数をカウントします。
SELECT COUNT(DISTINCT order_id) AS orders_with_items
FROM handson_ch05.orders, UNNEST(items) AS i;

結果は 1(order_id 1002 の空配列は展開されず消えるため)です。

  1. LEFT JOIN UNNEST に書き換えて同じ集計をします。
SELECT COUNT(DISTINCT order_id) AS all_orders
FROM handson_ch05.orders
LEFT JOIN UNNEST(items) AS i;

結果は 2 になり、明細の無い注文も母数に含まれます。「注文全体に対する明細ありの割合」のような分析では、この違いが結果を左右します。

後片付け:

bq rm -r -f -d PROJECT_ID:handson_ch05

この時点で handson_ch05 データセットは不要になるため削除します。次のハンズオンでは新しいデータセットを作り直します。

ハンズオン 5-4|JSON 型の列を JSON_VALUE / JSON_QUERY / LAX 系関数で読み解く

所要時間の目安: 5 分。サンドボックスで可・無料枠内(数百バイトの自作テーブル)。

  1. データセットを作り直し、JSON 型の列を持つテーブルを作成します。
bq mk --dataset --location=US PROJECT_ID:handson_ch05_json
CREATE TABLE handson_ch05_json.events AS
SELECT 1 AS event_id,
  JSON '{"user": {"id": 42, "plan": "pro"}, "tags": ["beta", "mobile"]}' AS payload
UNION ALL
SELECT 2,
  JSON '{"user": {"id": 43, "plan": "free"}, "tags": []}';
  1. スカラー値は JSON_VALUE、数値への変換には LAX_INT64、配列は JSON_QUERY_ARRAY で取り出します。
SELECT
  event_id,
  JSON_VALUE(payload, '$.user.plan') AS plan,
  LAX_INT64(JSON_QUERY(payload, '$.user.id')) AS user_id,
  JSON_QUERY_ARRAY(payload, '$.tags') AS tags
FROM handson_ch05_json.events
ORDER BY event_id;

期待される出力:

event_id | plan  | user_id | tags
1        | pro   | 42      | ["beta", "mobile"]
2        | free  | 43      | []

後片付け:

bq rm -r -f -d PROJECT_ID:handson_ch05_json

つまずきポイント

CROSS JOIN UNNEST(または単に , UNNEST(...))は空配列・NULL 配列の行を静かに除外します。集計の分母がいつの間にか小さくなる典型的な原因なので、明細 0 件の行も数えたい場面では最初から LEFT JOIN UNNEST を使う習慣にしましょう。
arr[OFFSET(n)] は範囲外アクセスでエラーになります。配列長が行によって異なる場合は SAFE_OFFSET(n) を使い、範囲外は NULL として扱いましょう。
JSON_VALUE はオブジェクトや配列などの非スカラー値を指すと NULL を返します。ネストしたオブジェクトごと取り出したいときは JSON_QUERY を使い、スカラー値だけを取り出したいときは JSON_VALUE を使う、と用途で切り分けます。
RECORD(STRUCT)のネストは 15 段階までという上限があります。また配列の配列は作れません。多段の繰り返し構造が必要なときは、STRUCT を 1 段挟んで ARRAY<STRUCT<inner_array ARRAY<...>>> のように組み立てます。

理解度チェック

Q1. BigQuery でネスト・繰り返しフィールドによる非正規化が推奨されるのはなぜですか?

JOIN はキーの一致するデータをノード間で突き合わせるためシャッフル(データ再配置)が発生し通信コストがかかりますが、関連データをあらかじめ 1 行にまとめておけば JOIN 自体が不要になるためです。ただしスタースキーマがすでに最適化されている場合は効果が小さいこともあります。

Q2. CROSS JOIN UNNEST と LEFT JOIN UNNEST の違いは何ですか?

CROSS JOIN UNNEST は展開対象の配列が空または NULL の行を結果から除外します。LEFT JOIN UNNEST はそのような行も 1 行として保持し、展開された列は NULL になります。明細 0 件の行も母数に含めたい集計では LEFT JOIN UNNEST を使います。

Q3. OFFSET(n) と SAFE_OFFSET(n) の違いは何ですか?

どちらも 0 始まりのインデックスで配列要素にアクセスしますが、OFFSET(n) は範囲外アクセスでエラーになるのに対し、SAFE_OFFSET(n) は範囲外の場合に NULL を返します。行ごとに配列長が異なる場合は SAFE_OFFSET が安全です。

Q4.(シナリオ)外部 API から受け取るイベントログを保存したい。フィールドは API の仕様変更によっていつ増減するか分からない。最も適した列の型はどれか?
A. STRUCT 型で固定スキーマを定義する B. JSON 型で保存する C. フィールドごとに個別の STRING 列へ分解する D. すべての値を 1 本の REPEATED STRING 列に詰め込む

B。JSON 型はスキーマ・オン・リードで、フィールドの増減があっても列定義を変更せずに扱えます。A の STRUCT は固定スキーマを強制するため頻繁な変更に弱く、C は列追加のたびに ALTER TABLE が必要でスケールしません。D はキーと値の対応が失われ、後から特定フィールドを取り出せなくなります。

Q5. STRUCT 型を使う主なメリットは何ですか?

フィールド名と型をスキーマとして固定できるため型チェックが効き、列指向ストレージの恩恵(使わないフィールドを読み飛ばす)を JSON 型より活かしやすくなります。構造が安定しているデータに向いています。

まとめと次章へ

次章では、フルスキャン課金のもとでスキャン量そのものを桁で減らすパーティショニングとクラスタリングを学びます。