- パーティショニングの 3 方式(時間単位列・取り込み時刻・整数範囲)と粒度の選び方
- require_partition_filter とパーティション有効期限で、事故と課金を未然に防ぐ方法
- クラスタリングの仕組み(ブロックソート)と、最大 4 列という制約の使い方
- パーティショニングとクラスタリング、どちらを選ぶか・両方使うべきかの判断基準
- CTAS で作った表を dry run と実処理バイト数で比較し、削減効果を自分の目で確認する
前提: サンドボックスで完走可(DDL と SELECT のみで構成。DML は使わない)
パーティショニングの 3 方式と粒度
第 1 章で、BigQuery のオンデマンドクエリは処理したバイト数で課金され、dry run で事前に見積もれることを学びました。第 4 章では CTAS(CREATE TABLE ... AS SELECT)でクエリ結果をそのままテーブルに固定する方法を学びました。この 2 つを組み合わせると、「テーブルの持ち方を変えるとスキャン量がどう変わるか」を自分の手で実測できます。パーティショニングとクラスタリングは、そのための BigQuery の 2 大機構です。
パーティション分割テーブルは、テーブルをセグメント(パーティション)に分割し、クエリがパーティション列の値でフィルタしていれば、一致しないパーティションを丸ごとスキャン対象から除外します。BigQuery には 3 つの方式があります。
| 方式 | パーティションが何で決まるか | 主な用途 |
|---|---|---|
| 時間単位列パーティション分割 | DATE / TIMESTAMP / DATETIME 型の列の値 | 「created_at で絞り込む」など、行に日付列がある一般的なケース |
| 取り込み時刻パーティション分割 | BigQuery がデータを取り込んだ時刻(疑似列 _PARTITIONTIME) | 行に使える日付列がない、またはロード時刻そのもので十分な場合 |
| 整数範囲パーティション分割 | INTEGER 列の値を開始・終了・間隔で区切った範囲 | 顧客 ID・店舗コードなど、日付ではない整数キーで分割したい場合 |
時間単位列パーティション分割の 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 を設定すると有効期限を解除できます。
既存の非パーティションテーブルは、直接パーティション化できません。CTAS でデータを読み直し、新しいパーティション分割テーブルとして作り直す必要があります(この章のハンズオンで実際に行います)。
クラスタリングの仕組みと列の使い方
クラスタ化テーブルでは、ストレージブロックがクラスタ列の値でソートされた状態に保たれます。新しいデータが増えると BigQuery がバックグラウンドで自動的に並べ替え(自動再クラスタリング)を行います。クエリがクラスタ列でフィルタすると、値が含まれていないブロックを丸ごと読み飛ばせます。
クラスタリングには次の制約があります。
- 最大 4 列。
CLUSTER BY col1, col2, col3, col4のように指定します - 列の順序が優先度。先頭の列から順にフィルタしないと最適化が効きません。
CLUSTER BY country, statusの場合、statusだけでフィルタしてもブロックプルーニングは効きません - 使える型は
BIGNUMERICBOOLDATEDATETIMEGEOGRAPHYINT64NUMERICRANGESTRINGTIMESTAMP。STRINGは先頭 1,024 文字だけがクラスタリングに使われます
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 で実際に確認します。
判断の流れをまとめると次のようになります。
ハンズオン
ここからは 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
サンドボックスで完走可・無料枠内。元テーブルは 16 列・約 7.5 GiB ですが、列を 4 つに絞って CTAS するため、処理量は数百 MB 〜 1 GB 程度に収まります。
- まず、公開データから列を絞って 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 値として使います)。 - 種テーブルを 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"); - この種テーブルから、パーティションなし版とパーティションあり版を作ります。以降はどちらも数百 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; - 同じ「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
サンドボックスで完走可・無料枠内。CTAS 部分は dry run で確認できますが、クラスタリングの効果は「実行後の実処理バイト数」でしか確認できません(前節の図 6-4 の通りです)。実行するクエリも数百 MB 級なので、無料枠(クエリ処理 1 TB/月)には十分収まります。
- クラスタ列に使う候補として、よく登場する乗車ステーションを確認します。
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を実際の値に置き換えてください。 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;- 半年分・特定ステーションだけを対象にした同一クエリを、クラスタリングなし版・あり版の両方に対して まず 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 日ぶん)が同じで、クラスタリングによるブロック単位の絞り込みは実行前の見積もりに反映されないためです。
- 次に 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 でデータを読み直し、新しいパーティション分割テーブルとして作り直す必要があります。本番テーブルで気づいた場合は、切り替え手順(新テーブル作成 → 参照先の切り替え → 旧テーブル削除)を計画してから着手してください。
フィルタ無しでクエリすると
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 日」で絞っても実行前に正確なコスト見積もりが得られません。
まとめと次章へ
- パーティショニングには時間単位列・取り込み時刻・整数範囲の 3 方式があり、日単位が既定の粒度。上限はテーブルあたり 10,000 パーティション
- require_partition_filter とパーティション有効期限は、フィルタ忘れの事故とストレージ費用を未然に防ぐ運用ルールとしてセットで設定する
- クラスタリングは最大 4 列、列順が優先度。64 MB を超えるテーブル・パーティションで効果が出る
- パーティションは dry run で正確な事前見積もりが得られるが、クラスタリングの効果は実行後の実処理バイト数でしか確認できない
- 既存テーブルへのパーティショニングの後付けは不可(CTAS で作り直す)。クラスタリングの後付けは可能だが、既存データは自動では再クラスタリングされない
次章では、ここで学んだパーティショニング・クラスタリングを前提に、料金モデルの全体像とコスト管理の実践(オンデマンドと editions の違い、カスタムクォータ、コスト管理ダッシュボード)を扱います。