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

パーティショニングとクラスタリング

スキャン量を桁で減らす 2 大機構。dry run でプルーニングを実測する

この章で学ぶこと

前提: サンドボックスで完走可(DDL と SELECT のみで構成。DML は使わない)

パーティショニングの 3 方式と粒度

第 1 章で、BigQuery のオンデマンドクエリは処理したバイト数で課金され、dry run で事前に見積もれることを学びました。第 4 章では CTAS(CREATE TABLE ... AS SELECT)でクエリ結果をそのままテーブルに固定する方法を学びました。この 2 つを組み合わせると、「テーブルの持ち方を変えるとスキャン量がどう変わるか」を自分の手で実測できます。パーティショニングとクラスタリングは、そのための BigQuery の 2 大機構です。

パーティション分割テーブルは、テーブルをセグメント(パーティション)に分割し、クエリがパーティション列の値でフィルタしていれば、一致しないパーティションを丸ごとスキャン対象から除外します。BigQuery には 3 つの方式があります。

パーティション分割なし テーブル全体(全 365 日ぶん) スキャン: 全体 クエリ パーティション分割あり(日次) 6/10 6/11 6/12 6/13 6/14 6/15 一致 スキャン: 1/6 のみ クエリ
図 6-1 パーティション分割なしでは対象パーティション以外もまとめてスキャンされるが、分割ありなら一致したパーティションだけがスキャンされる。
方式パーティションが何で決まるか主な用途
時間単位列パーティション分割DATE / TIMESTAMP / DATETIME 型の列の値「created_at で絞り込む」など、行に日付列がある一般的なケース
取り込み時刻パーティション分割BigQuery がデータを取り込んだ時刻(疑似列 _PARTITIONTIME)行に使える日付列がない、またはロード時刻そのもので十分な場合
整数範囲パーティション分割INTEGER 列の値を開始・終了・間隔で区切った範囲顧客 ID・店舗コードなど、日付ではない整数キーで分割したい場合
時間単位列 行 (created_at 列) 列の値で分割 パーティション: 2017-06-15 取り込み時刻 行 (到着時刻) 自動的に割当て _PARTITIONTIME 整数範囲 行 (customer_id=45) RANGE_BUCKET パーティション: [40,50)
図 6-2 3 つのパーティショニング方式は、行がどの基準でパーティションに割り当てられるかが異なる。

時間単位列パーティション分割の DDL は次のように書きます。列を直接指定すれば日単位、TIMESTAMP_TRUNC / DATE_TRUNC で丸めれば月単位・年単位になります。

-- 日単位(列を直接指定)
CREATE TABLE mydataset.newtable (transaction_id INT64, transaction_date DATE)
PARTITION BY transaction_date;

-- 月単位(DATE_TRUNC で丸める)
CREATE TABLE mydataset.newtable (transaction_id INT64, transaction_date DATE)
PARTITION BY DATE_TRUNC(transaction_date, MONTH);

整数範囲パーティション分割は RANGE_BUCKET で開始・終了・間隔を指定します。

CREATE TABLE mydataset.newtable (customer_id INT64, date1 DATE)
PARTITION BY RANGE_BUCKET(customer_id, GENERATE_ARRAY(0, 100, 10))
OPTIONS (require_partition_filter = TRUE);

取り込み時刻パーティション分割には行の値ではなく BigQuery が付与した疑似列が使われます。_PARTITIONTIME は取り込み時刻をパーティションの境界(時間単位なら時、日単位なら日)に切り詰めた値、_PARTITIONDATE はその UTC 日付です。行に日付列が無いデータを取り込む場合に使います。

粒度は 4 種類から選びます。日単位が既定です。

粒度適するケース
時間単位6 か月未満の期間で大量データが集中する場合
日単位(既定)広い日付範囲に渡る、または継続的に追加されるデータ
月単位・年単位広い日付範囲に対してデータ量が比較的少ない場合

パーティション数の上限

1 テーブルあたりのパーティション数は最大 10,000(2026-08 時点、Quotas and limits)です。1 つのクエリ・ロードジョブが変更できるのは最大 4,000 パーティション、取り込み時刻パーティション分割テーブルは 1 日あたり最大 11,000 回のパーティション変更が可能です。日次で 10,000 パーティションというと約 27 年分に相当するため、通常の運用ではまず問題になりません。上限に近づく場合は、粒度を月単位・年単位に落とすか、クラスタリングの併用を検討します。

require_partition_filter とパーティションの有効期限

require_partition_filter を有効にすると、パーティション列(または _PARTITIONTIME / _PARTITIONDATE)でのフィルタが無いクエリをエラーで拒否できます。フィルタ漏れによる全件スキャン事故を防ぐ、実務上ほぼ必須のオプションです。

-- 既存テーブルに後から設定する場合
ALTER TABLE mydataset.mypartitionedtable
SET OPTIONS (
  require_partition_filter = true,
  partition_expiration_days = 90
);

partition_expiration_days は、指定した日数を過ぎたパーティションを自動削除します。ログテーブルなど「直近 N 日だけ持てばよい」データに設定しておくと、ストレージ費用の上限を切ってくれます。NULL を設定すると有効期限を解除できます。

実務の視点: require_partition_filter は「後から気づいて直す」ものではなく、パーティション分割テーブルを作る時点でセットにする運用ルールにしておくべきです。フィルタ忘れの全件スキャンは、パーティショニングした意味を丸ごと無効化する典型的な事故だからです。

既存の非パーティションテーブルは、直接パーティション化できません。CTAS でデータを読み直し、新しいパーティション分割テーブルとして作り直す必要があります(この章のハンズオンで実際に行います)。

クラスタリングの仕組みと列の使い方

クラスタ化テーブルでは、ストレージブロックがクラスタ列の値でソートされた状態に保たれます。新しいデータが増えると BigQuery がバックグラウンドで自動的に並べ替え(自動再クラスタリング)を行います。クエリがクラスタ列でフィルタすると、値が含まれていないブロックを丸ごと読み飛ばせます。

クラスタリングなし(挿入順のまま) ABC BAC CAB ACB BCA CBA 6 ブロックすべてに A が含まれるため、全部読む必要がある。 クラスタリングあり(customer_id でソート済み) AAA AAA BBB BBB CCC CCC A のブロックだけ読めばよい。残り 4 ブロックはスキップできる。
図 6-3 クラスタ化列でソートすると同じ値のブロックがまとまり、フィルタに一致しないブロックを読み飛ばせる。

クラスタリングには次の制約があります。

CREATE TABLE mydataset.myclusteredtable
(
  customer_id STRING,
  region STRING,
  transaction_amount NUMERIC
)
CLUSTER BY customer_id, region
OPTIONS (description = 'customer_id, region の順でクラスタ化');

クラスタリングはストレージ料金・クエリ料金の課金対象ですが、バッチロード・テーブルコピー・自動再クラスタリング・エクスポートは無料操作です。パーティショニングと併用すると、各パーティション内でさらにクラスタ列によるソートが保たれます。

パーティショニングとクラスタリング、どちらを選ぶか

両者の一番の違いは、クエリを実行する前にコストが分かるかどうかです。パーティション分割テーブルはメタデータでパーティションごとの情報を管理しているため、dry run で正確なスキャン量の見積もりが得られます。一方クラスタ化テーブルは、実際にスキャンするブロック数がクエリ実行前には分からないため、正確な見積もりは実行後にしか確定しません。この違いはハンズオン 6-2 で実際に確認します。

パーティション dry run 見積もり パーティションプルーニングを反映済み 実行 実際のバイト数 見積もりとほぼ一致 クラスタリング dry run 見積もり ブロック数不明のため保守的 実行 実際のバイト数 見積もりより少ない
図 6-4 パーティションは実行前から正確な見積もりが得られるが、クラスタリングは実行後にしかブロックプルーニングの効果が確定しない。
実務の視点: クラスタリングが効くのは、非パーティションテーブルやパーティションのサイズが 64 MB を超える場合です。64 MB 以下のテーブル・パーティションにもクラスタリングは設定できますが、効果はほとんど無視できます。逆にパーティションは、平均サイズが 10 GB を下回るほど細かく刻むと、メタデータの肥大化で参照が遅くなることがあります。「小さいテーブルに機構だけ足す」のは、コストばかり増えて効果が出ない典型的なアンチパターンです。

判断の流れをまとめると次のようになります。

頻繁に絞り込む日付列がある? いいえ はい パーティションなし テーブルは 64 MB 超? いいえ はい パーティションのみで十分 日付以外でも絞り込む列がある? いいえ はい パーティショニングのみ パーティション+ クラスタリング併用
図 6-5 列のカーディナリティとテーブルサイズ、絞り込みパターンからパーティショニング・クラスタリングの採否を判断する。

ハンズオン

ここからは bigquery-public-data.new_york_citibike.citibike_trips(2026-08 時点でのテーブルメタデータで 58,937,715 行・16 列・約 7.5 GiB。開始時刻 starttime は DATETIME 型)を使って、パーティショニングとクラスタリングの効果を実測します。ロケーションは US(このテーブルの所在地)に合わせ、演習用データセット handson_ch06 を US に作ります。

bq mk --location=US --dataset PROJECT_ID:handson_ch06
ハンズオン 6-1|CTAS で日次パーティション表を作り、dry run でスキャン量を before/after 比較する

サンドボックスで完走可・無料枠内。元テーブルは 16 列・約 7.5 GiB ですが、列を 4 つに絞って CTAS するため、処理量は数百 MB 〜 1 GB 程度に収まります。

  1. まず、公開データから列を絞って 1 年分だけ取り出した「種テーブル」を作ります。starttime は非パーティションテーブルの列なので、WHERE で期間を絞っても元テーブルのスキャン量そのものは変わりません(これがこの章の出発点です)。実行前に dry run で確認しておきます。
    bq query --use_legacy_sql=false --dry_run '
    SELECT starttime, tripduration, start_station_name, usertype
    FROM `bigquery-public-data.new_york_citibike.citibike_trips`
    WHERE starttime BETWEEN DATETIME("2017-01-01") AND DATETIME("2017-12-31 23:59:59")'

    starttime(DATETIME)と tripduration(INT64)だけで 1 行あたり固定 16 論理バイト(公式の Data type sizes 表で各 8 バイト)。58,937,715 行ぶんで少なくとも約 900 MiB、文字列 2 列を含めるとさらに増えます。表示された見積もりバイト数を控えておいてください(この後の Before 値として使います)。

  2. 種テーブルを CTAS で作成します。
    CREATE OR REPLACE TABLE handson_ch06.trips_flat AS
    SELECT
      starttime,
      tripduration,
      start_station_name,
      usertype
    FROM `bigquery-public-data.new_york_citibike.citibike_trips`
    WHERE starttime BETWEEN DATETIME("2017-01-01") AND DATETIME("2017-12-31 23:59:59");
  3. この種テーブルから、パーティションなし版とパーティションあり版を作ります。以降はどちらも数百 MB 級の小さい表なので、何度作り直しても無料枠を圧迫しません。
    CREATE OR REPLACE TABLE handson_ch06.trips_no_partition AS
    SELECT * FROM handson_ch06.trips_flat;
    
    CREATE OR REPLACE TABLE handson_ch06.trips_daily
    PARTITION BY DATE(starttime)
    AS
    SELECT * FROM handson_ch06.trips_flat;
  4. 同じ「1 日だけ」を対象にした集計クエリを、両方のテーブルに対して dry run します。
    -- Before: パーティションなし
    bq query --use_legacy_sql=false --dry_run '
    SELECT usertype, COUNT(*) AS trips, AVG(tripduration) AS avg_duration
    FROM handson_ch06.trips_no_partition
    WHERE DATE(starttime) = "2017-06-15"
    GROUP BY usertype'
    
    -- After: 日次パーティション
    bq query --use_legacy_sql=false --dry_run '
    SELECT usertype, COUNT(*) AS trips, AVG(tripduration) AS avg_duration
    FROM handson_ch06.trips_daily
    WHERE DATE(starttime) = "2017-06-15"
    GROUP BY usertype'

Before(trips_no_partition)は 365 日ぶん全体を毎回スキャンするため、見積もりバイト数は種テーブルの合計サイズとほぼ同じになります。After(trips_daily)は一致する 1 パーティションしかスキャンしないため、単純計算でおよそ 365 分の 1 に近づきます(1 日あたりの利用件数にはばらつきがあるため、実際の比率は多少前後します)。dry run の見積もりバイト数がこの通り大きく減ることを、自分の画面で確認してください。

後片付け: trips_flat と trips_no_partition はもう使わないため削除します。trips_daily はハンズオン 6-2 で使うため残します。

bq rm -f -t PROJECT_ID:handson_ch06.trips_flat
bq rm -f -t PROJECT_ID:handson_ch06.trips_no_partition
ハンズオン 6-2|クラスタ化テーブルを作り、実行後の処理バイト数でブロックプルーニングの効果を確認する

サンドボックスで完走可・無料枠内。CTAS 部分は dry run で確認できますが、クラスタリングの効果は「実行後の実処理バイト数」でしか確認できません(前節の図 6-4 の通りです)。実行するクエリも数百 MB 級なので、無料枠(クエリ処理 1 TB/月)には十分収まります。

  1. クラスタ列に使う候補として、よく登場する乗車ステーションを確認します。
    SELECT start_station_name, COUNT(*) AS n
    FROM handson_ch06.trips_daily
    GROUP BY start_station_name
    ORDER BY n DESC
    LIMIT 5;

    出力された上位のステーション名を 1 つ選び、以降の STATION_NAME を実際の値に置き換えてください。

  2. trips_daily と同じデータから、start_station_name でクラスタ化した版を作ります。パーティションは引き継ぎ、クラスタリングを追加する形です。
    CREATE OR REPLACE TABLE handson_ch06.trips_daily_clustered
    PARTITION BY DATE(starttime)
    CLUSTER BY start_station_name
    AS
    SELECT * FROM handson_ch06.trips_daily;
  3. 半年分・特定ステーションだけを対象にした同一クエリを、クラスタリングなし版・あり版の両方に対して まず dry run します。
    bq query --use_legacy_sql=false --dry_run '
    SELECT DATE(starttime) AS d, COUNT(*) AS trips
    FROM handson_ch06.trips_daily
    WHERE DATE(starttime) BETWEEN "2017-01-01" AND "2017-06-30"
      AND start_station_name = "STATION_NAME"
    GROUP BY d
    ORDER BY d'

    2 つの dry run 結果を見比べると、見積もりバイト数はほぼ同じはずです。対象になるパーティション数(181 日ぶん)が同じで、クラスタリングによるブロック単位の絞り込みは実行前の見積もりに反映されないためです。

  4. 次に dry run を外して実際に実行し、コンソールの「ジョブ情報」タブ(または bq show -j --format=prettyjson JOB_ID の statistics.query.totalBytesProcessed)で実処理バイト数を比較します。
    bq query --use_legacy_sql=false '
    SELECT DATE(starttime) AS d, COUNT(*) AS trips
    FROM handson_ch06.trips_daily_clustered
    WHERE DATE(starttime) BETWEEN "2017-01-01" AND "2017-06-30"
      AND start_station_name = "STATION_NAME"
    GROUP BY d
    ORDER BY d'

クラスタリングなし版は各パーティション内をまんべんなく読むため、実処理バイト数は dry run の見積もりとほぼ一致します。クラスタリングあり版は start_station_name でブロックが整列しているため、対象ステーションを含まないブロックが読み飛ばされ、実処理バイト数が見積もりより少なくなります。「dry run では差が見えないのに、実行後には差が出る」という図 6-4 の内容を、自分の実行結果で確認できたはずです。

後片付け: ここまでで作った演習データセットをまるごと削除します。

bq rm -r -f -d PROJECT_ID:handson_ch06

つまずきポイント

既存テーブルは直接パーティション化できない
「後からパーティションを追加する」ALTER TABLE 構文はありません。CTAS でデータを読み直し、新しいパーティション分割テーブルとして作り直す必要があります。本番テーブルで気づいた場合は、切り替え手順(新テーブル作成 → 参照先の切り替え → 旧テーブル削除)を計画してから着手してください。
require_partition_filter のエラーに慌てない
フィルタ無しでクエリすると Cannot query over table 'project_id.dataset.table' without a filter that can be used for partition elimination というエラーになります。バグではなく、全件スキャンを未然に防ぐための正常な拒否です。WHERE 句にパーティション列の条件を足してください。
クラスタリングを足しても既存データは再クラスタリングされない
bq update --clustering_fields や ALTER TABLE でクラスタリング列を後から追加・変更しても、それは新しく書き込まれるデータにしか適用されません。既存データ全体を並べ替えるには UPDATE table SET col = col WHERE true のような自己代入 DML で書き戻す必要があります(DML はサンドボックスでは実行できないため、課金プロジェクトが必要です)。
パーティション列を関数でラップするとプルーニングが効かない
WHERE FORMAT_DATE('%Y-%m-%d %H', ts) = '2025-03-28 20' のようにパーティション列を関数に通すと、BigQuery はどのパーティションが対象か事前に判断できず、全パーティションを読みます。サブクエリの結果と比較する動的な条件(WHERE t1.ts = (SELECT timestamp FROM table3))も同様です。パーティション列は生の値のまま比較してください。

理解度チェック

Q1. パーティショニングとクラスタリングの使い分けの基準は?

パーティショニングは、頻繁にフィルタする低カーディナリティの日付・整数列があり、かつ実行前に正確なコスト見積もりが欲しい場合に使います。クラスタリングは、パーティション内をさらに絞り込みたい高カーディナリティの列(最大 4 列)に使い、非パーティションテーブルやパーティションが 64 MB を超える場合に効果が出ます。多くの実務では両方を併用します。

Q2. 既存の非パーティションテーブルに、後からパーティショニングを追加できますか?

直接はできません。ALTER TABLE でパーティション方式を追加する構文は無く、CTAS で元データを読み直して新しいパーティション分割テーブルを作り直す必要があります。

Q3. クラスタ化テーブルに対するクエリの dry run 見積もりは、実行後の実際のバイト数と一致しますか?

一致しません。クラスタ化テーブルはスキャンするブロック数が実行前には確定しないため、dry run の見積もりは保守的な値になります。実際のコストはクエリ実行後にしか確定しないため、正確な比較にはジョブ情報の実処理バイト数を見る必要があります。

Q4. require_partition_filter を有効にしたテーブルに、パーティション列の条件なしでクエリすると何が起きますか?

「Cannot query over table ... without a filter that can be used for partition elimination」というエラーになり、クエリは実行されません。全件スキャンを未然に防ぐための意図した挙動です。

Q5.(シナリオ)毎日 100 万行追記されるイベントログテーブルがある。分析は直近 7 日間かつ特定の customer_id で絞り込むことが多い。最も適切な設計は?
A. パーティションなし B. created_at で日次パーティション + customer_id で CLUSTER BY C. customer_id で日次パーティション D. created_at で CLUSTER BY のみ(パーティションなし)

B。日次パーティションで「直近 7 日」の絞り込みに正確な事前見積もりを得つつ、customer_id のクラスタリングでパーティション内をさらに絞り込めます。A は論外です。C は customer_id が高カーディナリティすぎて、日単位よりずっと細かい粒度でパーティションを切ることになり、10,000 パーティション上限や大量の小さいパーティションによるメタデータ肥大化を招きます。D はパーティショニングが無いため、「直近 7 日」で絞っても実行前に正確なコスト見積もりが得られません。

まとめと次章へ

次章では、ここで学んだパーティショニング・クラスタリングを前提に、料金モデルの全体像とコスト管理の実践(オンデマンドと editions の違い、カスタムクォータ、コスト管理ダッシュボード)を扱います。